|
话题 Topic |
本主题涵盖... This topic covers... |
|
资源标识 Resource identification |
如何识别 API 中的资源 How to identify resources in an API |
|
标准方法 Standard methods |
用于面向资源的 API 的标准方法集 The set of standard methods for use in resource-oriented APIs |
|
部分更新和检索 Partial updates and retrievals |
如何与部分资源交互 How to interact with portions of resources |
|
自定义方法 Custom methods |
在面向资源的 API 中使用自定义(非标准)方法 Using custom (non-standard) methods in resource-oriented APIs |
|
长时间运行的操作 Long-running operations |
如何处理非瞬时方法 How to handle methods that are not instantaneous |
|
可重新运行的作业 Rerunnable jobs |
在 API 中运行重复的自定义功能 Running repeated custom functionality in an API |
|
单例子资源 Singleton sub-resources |
隔离部分资源数据 Isolating portions of resource data |
|
交叉引用 Cross references |
如何在 API 中引用其他资源 How to reference other resources in an API |
|
协会资源 Association resources |
如何管理与元数据的多对多关系 How to manage many-to-many relationships with metadata |
|
添加和删除自定义方法 Add and remove custom methods |
如何在没有元数据的情况下管理多对多关系 How to manage many-to-many relationships without metadata |
|
多态性 Polymorphism |
设计具有动态类型属性的资源 Designing resources with dynamically-typed attributes |
|
复制和移动 Copy and move |
在 API 中复制和重新定位资源 Duplicating and relocating resources in an API |
|
批量操作 Batch operations |
扩展方法以原子方式应用于资源组 Extending methods to apply to groups of resources atomically |
|
基于标准的删除 Criteria-based deletion |
根据一组过滤条件删除多个资源 Deleting multiple resources based on a set of filter criteria |
|
匿名写 Anonymous writes |
将不可寻址的数据提取到 API 中 Ingesting unaddressable data into an API |
|
分页 Pagination |
在一口大小的块中消耗大量数据 Consuming large amounts of data in bite-sized chunks |
|
过滤 Filtering |
根据用户指定的过滤器限制结果集 Limiting result sets according to a user-specified filter |
|
导入和导出 Importing and exporting |
通过直接与存储系统交互将数据移入或移出 API Moving data into or out of an API by interacting directly with a storage system |
|
版本控制和兼容性 Versioning and compatibility |
定义版本控制 API 的兼容性和策略 Defining compatibility and strategies for versioning APIs |
|
软删除 将资源移动到 Soft deletion Moving resources to the |
“API 回收站” “API recycle bin” |
|
请求重复数据删除 Request deduplication |
防止 API 因网络中断而重复工作 Preventing duplicate work due to network interruptions in APIs |
|
请求验证 Request validation |
允许在“安全模式”下调用 API 方法 Allowing API methods to be called in “safe mode” |
|
资源修订 Resource revisions |
跟踪资源变更历史 Tracking resource change history |
|
请求重审 Request retrial |
安全重试 API 请求的算法 Algorithms for safely retrying API requests |
|
请求认证 Request authentication |
验证请求的真实性和未被篡改 Verifying that requests are authentic and untampered with |
有关在线信息以及订购这些和其他 Manning 书籍的信息,请访问www.manning.com。出版商在大量订购这些书籍时提供折扣。
For online information and ordering of these and other Manning books, please visit www.manning.com. The publisher offers discounts on these books when ordered in quantity.
获取更多资讯,请联系
For more information, please contact
特约营业部
Special Sales Department
曼宁出版公司
Manning Publications Co.
鲍德温路 20 号
20 Baldwin Road
邮政信箱 761
PO Box 761
纽约州庇护岛 11964
Shelter Island, NY 11964
邮箱:orders@manning.com
Email: orders@manning.com
©2021 Manning Publications Co. 保留所有权利。
©2021 by Manning Publications Co. All rights reserved.
未经出版商事先书面许可,不得以任何形式或通过电子、机械、影印或其他方式复制、存储在检索系统中或传播本出版物的任何部分。
No part of this publication may be reproduced, stored in a retrieval system, or transmitted, in any form or by means electronic, mechanical, photocopying, or otherwise, without prior written permission of the publisher.
制造商和销售商用来区分其产品的许多名称都被声明为商标。如果这些名称出现在书中,并且 Manning Publications 知道商标声明,则这些名称已印在首字母大写或全部大写中。
Many of the designations used by manufacturers and sellers to distinguish their products are claimed as trademarks. Where those designations appear in the book, and Manning Publications was aware of a trademark claim, the designations have been printed in initial caps or all caps.
♾认识到保存所写内容的重要性,Manning 的政策是将我们出版的书籍印刷在无酸纸上,我们为此尽最大努力。还认识到我们有责任保护地球资源,Manning 书籍印刷的纸张至少有 15% 被回收利用,并且在不使用元素氯的情况下进行加工。
♾ Recognizing the importance of preserving what has been written, it is Manning’s policy to have the books we publish printed on acid-free paper, and we exert our best efforts to that end. Recognizing also our responsibility to conserve the resources of our planet, Manning books are printed on paper that is at least 15 percent recycled and processed without the use of elemental chlorine.
|
|
曼宁出版公司 Manning Publications Co. 20鲍德温路技术 20 Baldwin Road Technical 邮政信箱 761 PO Box 761 纽约州庇护岛 11964 Shelter Island, NY 11964 |
|
开发编辑: Development editor: |
克里斯蒂娜·泰勒 Christina Taylor |
|
技术开发编辑: Technical development editor: |
克林克 Al Krinker |
|
评论编辑: Review editor: |
伊万·马丁诺维奇 Ivan Martinović |
|
制作编辑: Production editor: |
迪尔德丽·希亚姆 Deirdre S. Hiam |
|
文案编辑: Copy editor: |
米歇尔·米切尔 Michele Mitchell |
|
校对: Proofreader: |
克里黑尔斯 Keri Hales |
|
技术校对: Technical proofreader: |
卡斯滕·斯特罗拜克 Karsten Strøbæk |
|
排字机: Typesetter: |
丹尼斯·达林尼克 Dennis Dalinnik |
|
封面设计师: Cover designer: |
玛丽亚都铎 Marija Tudor |
书号:9781617295850
ISBN: 9781617295850
给凯和卢卡。你真棒。
To Kai and Luca. You are awesome.
第1部分 介绍
Part 1 Introduction
1个 API简介
1.1 什么是网络 API?
1.2 为什么 API 很重要?
1.3 什么是面向资源的API?
1.3 What are resource-oriented APIs?
1.4 是什么让 API 变得“好”?
2个 API设计模式简介
2 Introduction to API design patterns
2.1 什么是API设计模式?
2.1 What are API design patterns?
2.2 为什么API设计模式很重要?
2.2 Why are API design patterns important?
2.3 API设计模式剖析
2.3 Anatomy of an API design pattern
2.4 案例研究:Twapi,一种类似 Twitter 的 API
2.4 Case study: Twapi, a Twitter-like API
第2部分 设计原则
Part 2 Design principles
3个 命名
3 Naming
3.1 为什么名称很重要?
3.2 什么使名字“好”?
3.3 语言、语法和句法
3.3 Language, grammar, and syntax
3.4 背景
3.4 Context
3.5 数据类型和单位
3.6 Case study: What happens when you choose bad names?
3.7 练习
3.7 Exercises
4个 资源范围和层次结构
4 Resource scope and hierarchy
4.1 什么是资源布局?
4.2 选择正确的关系
4.2 Choosing the right relationship
Do you need a relationship at all?
4.3 反模式
4.3 Anti-patterns
4.4 练习
4.4 Exercises
5个 数据类型和默认值
5.1 数据类型介绍
5.1 Introduction to data types
5.2 布尔值
5.2 Booleans
5.3 数字
5.3 Numbers
5.4 字符串
5.4 Strings
5.5 枚举
5.5 Enumerations
5.6 列表
5.6 Lists
5.7 地图
5.7 Maps
5.8 练习
5.8 Exercises
第 3 部分 基础知识
Part 3 Fundamentals
6个 资源标识
6.1 什么是标识符?
6.2 什么是好的标识符?
6.2 What makes a good identifier?
Readable, shareable, and verifiable
6.3 一个好的标识符是什么样子的?
6.3 What does a good identifier look like?
Hierarchy and uniqueness scope
6.4 实施
6.4 Implementation
6.5 UUID 怎么样?
6.6 练习
6.6 Exercises
7 标准方法
7.1 动机
7.1 Motivation
7.2 概述
7.2 Overview
7.3 实施
7.3 Implementation
Which methods should be supported?
7.4 权衡
7.4 Trade-offs
7.5 练习
7.5 Exercises
8个 部分更新和检索
8 Partial updates and retrievals
8.1 动机
8.1 Motivation
8.2 概述
8.2 Overview
8.3 实施
8.3 Implementation
Updating dynamic data structures
8.4 权衡
8.4 Trade-offs
8.5 练习
8.5 Exercises
9 自定义方法
9.1 动机
9.1 Motivation
Why not just standard methods?
9.2 概述
9.2 Overview
9.3 实施
9.3 Implementation
9.4 权衡
9.4 Trade-offs
9.5 练习
9.5 Exercises
10 长时间运行的操作
10.1 动机
10.1 Motivation
10.2 概述
10.2 Overview
10.3 实施
10.3 Implementation
Pausing and resuming operations
10.4 权衡
10.4 Trade-offs
10.5 练习
10.5 Exercises
11 可重新运行的作业
11.1 动机
11.1 Motivation
11.2 概述
11.2 Overview
11.3 实施
11.3 Implementation
11.4 权衡
11.4 Trade-offs
11.5 练习
11.5 Exercises
第 4 部分 资源关系
Part 4 Resource relationships
12 单例子资源
12.1 动机
12.1 Motivation
Why should we use a singleton sub-resource?
12.2 概述
12.2 Overview
12.3 实施
12.3 Implementation
12.4 权衡
12.4 Trade-offs
12.5 练习
12.5 Exercises
13 交叉引用
13.1 动机
13.1 Motivation
13.2 概述
13.2 Overview
13.3 实施
13.3 Implementation
13.4 权衡
13.4 Trade-offs
13.5 练习
13.5 Exercises
14 协会资源
14.1 动机
14.1 Motivation
14.2 概述
14.2 Overview
14.3 实施
14.3 Implementation
Naming the association resource
14.4 权衡
14.4 Trade-offs
14.5 练习
14.5 Exercises
15 添加和删除自定义方法
15 Add and remove custom methods
15.1 动机
15.1 Motivation
15.2 概述
15.2 Overview
15.3 实施
15.3 Implementation
15.4 权衡
15.4 Trade-offs
15.5 练习
15.5 Exercises
16 多态性
16 Polymorphism
16.1 动机
16.1 Motivation
16.2 概述
16.2 Overview
16.3 实施
16.3 Implementation
Deciding when to use polymorphic resources
16.4 权衡
16.4 Trade-offs
16.5 练习
16.5 Exercises
第 5 部分 集体行动
Part 5 Collective operations
17 复制和移动
17.1 动机
17.1 Motivation
17.2 概述
17.2 Overview
17.3 实施
17.3 Implementation
17.4 权衡
17.4 Trade-offs
17.5 练习
17.5 Exercises
18 批量操作
18.1 动机
18.1 Motivation
18.2 概述
18.2 Overview
18.3 实施
18.3 Implementation
18.4 权衡
18.4 Trade-offs
18.5 习题
18.5 Exercises
19 基于标准的删除
19.1 动机
19.1 Motivation
19.2 概述
19.2 Overview
19.3 实施
19.3 Implementation
19.4 权衡
19.4 Trade-offs
19.5 练习
19.5 Exercises
20 匿名写
20.1 动机
20.1 Motivation
20.2 概述
20.2 Overview
20.3 实施
20.3 Implementation
20.4 权衡
20.4 Trade-offs
20.5 习题
20.5 Exercises
21 分页
21 Pagination
21.1 动机
21.1 Motivation
21.2 概述
21.2 Overview
21.3 实施
21.3 Implementation
21.4 权衡
21.4 Trade-offs
21.5 反模式:偏移量和限制
21.5 Anti-pattern: Offsets and limits
21.6 习题
21.6 Exercises
22 过滤
22 Filtering
22.1 动机
22.1 Motivation
22.2 概述
22.2 Overview
22.3 实施
22.3 Implementation
22.4 权衡
22.4 Trade-offs
22.5 习题
22.5 Exercises
23 导入和导出
23.1 动机
23.1 Motivation
23.2 概述
23.2 Overview
23.3 实施
23.3 Implementation
Interacting with storage systems
Converting between resources and bytes
23.4 权衡
23.4 Trade-offs
23.5 习题
23.5 Exercises
第 6 部分 安全保障
Part 6 Safety and security
24 版本控制和兼容性
24 Versioning and compatibility
24.1 动机
24.1 Motivation
24.2 概述
24.2 Overview
Defining backward compatibility
24.3 实施
24.3 Implementation
24.4 权衡
24.4 Trade-offs
Stability vs. new functionality
24.5 习题
24.5 Exercises
25 软删除
25.1 动机
25.1 Motivation
25.2 概述
25.2 Overview
25.3 实施
25.3 Implementation
Adding soft delete across versions
25.4 权衡
25.4 Trade-offs
25.5 习题
25.5 Exercises
26 请求重复数据删除
26.1 动机
26.1 Motivation
26.2 概述
26.2 Overview
26.3 实施
26.3 Implementation
26.4 权衡
26.4 Trade-offs
26.5 习题
26.5 Exercises
27 请求验证
27.1 动机
27.1 Motivation
27.2 概述
27.2 Overview
27.3 实施
27.3 Implementation
27.4 权衡
27.4 Trade-offs
27.5 习题
27.5 Exercises
28 资源修订
28.1 动机
28.1 Motivation
28.2 概述
28.2 Overview
28.3 实施
28.3 Implementation
28.4 权衡
28.4 Trade-offs
28.5 习题
28.5 Exercises
29 请求重审
29.1 动机
29.1 Motivation
29.2 概述
29.2 Overview
29.3 实施
29.3 Implementation
29.4 权衡
29.4 Trade-offs
29.5 习题
29.5 Exercises
30 请求认证
30.1 动机
30.1 Motivation
30.2 概述
30.2 Overview
30.3 实施
30.3 Implementation
Registration and credential exchange
Generating and verifying raw signatures
30.4 权衡
30.4 Trade-offs
30.5 练习
30.5 Exercises
它始于一个架子鼓。2019 年夏天,我的一个朋友用电子鼓教我打鼓,我全心全意地接受了它。有时我真的会打鼓,但我花了相当多的时间编写代码,使用 MIDI SysEx 命令与我的架子鼓配置进行交互。
It started with a drum kit. In the summer of 2019, a friend of mine got me into drumming with an electronic kit, and I embraced it wholeheartedly. Sometimes I would actually play the drums, but I spent a rather larger proportion of my time writing code to interact with my drum kit’s configuration using MIDI SysEx commands.
当 COVID-19 大流行来袭时,在考虑我当地教会的音频/视频需求方面,我突然有了相当不同的优先顺序,既是在我们远程敬拜时,也是在考虑我们如何再次见面时。这涉及学习 VISCA、NDI 和 OSC(用于相机和混音器)等协议,以及与 Zoom、VLC、PowerPoint、Stream Deck 等更多面向软件的集成。
When the COVID-19 pandemic hit, I suddenly had rather different priorities in terms of considering the audio/visual needs of my local church, both while we were worshiping remotely and considering how we might meet in person again. This involved learning about protocols such as VISCA, NDI, and OSC (for cameras and audio mixers) as well as more software-oriented integration with Zoom, VLC, PowerPoint, Stream Deck, and more.
这些项目没有大量的业务逻辑。几乎所有的代码都是集成代码,这既令人沮丧又极大地增强了能力。这是令人沮丧的,因为协议的文档模糊不清,或者并不是真正为我想要实现的用途而设计的,或者只是彼此不一致。它是强大的,因为一旦你破解了集成方面的问题,你就可以真正轻松地编写有用的应用程序,站在多个巨人的肩膀上。
These projects don’t have huge amounts of business logic. Almost all the code is integration code, which is at once frustrating and hugely empowering. It’s frustrating because of protocols that are obscurely documented or aren’t really designed for the kind of usage I’m trying to achieve, or are just inconsistent with each other. It’s empowering because once you’ve cracked the integration aspect, you can write useful apps really easily, standing on the shoulders of multiple giants.
虽然我在过去几年的经验主要是本地集成,但同样的挫折和授权的平衡也适用于 Web API。我获得新 Web API 的每一次经历都会产生情绪反应曲线,包括兴奋、困惑、烦恼、接受和最终不安的平静。一旦你彻底理解了一个强大的 API,感觉就像你是一个宏伟管弦乐队的指挥,准备好演奏你提供的任何音乐——即使小提琴演奏者的音符只是最终一致并且你必须使用不同颜色的指挥棒对于黄铜部分,没有明显的原因。
While my experience over the past couple of years has been primarily local integration, the same balance of frustration and empowerment applies with web APIs. Every experience I’ve had of picking up a new web API has had a curve of emotional responses, including some mix of excitement, bewilderment, annoyance, acceptance, and eventual uneasy peace. Once you thoroughly understand a powerful API, it feels like you’re the conductor of a magnificent orchestra, ready to play whatever music you provide—even if the violin players’ notes are only eventually consistent and you have to use a different color of baton for the brass section for no obvious reason.
本书不会自行改变这一点。这只是一本书。但如果您阅读它并遵循它的指导,您可以帮助改变用户的体验。如果很多人阅读它并遵循它的指导,我们可能会一起朝着更一致、更少令人沮丧的 Web API 体验迈进。
This book won’t change that on its own. It’s only a book. But if you read it and follow its guidance, you can help to change the experience for your users. If lots of people read it and follow its guidance, together we might move the needle toward a more consistent and less frustrating web API experience.
重要的是要理解本书的价值,而不是其各部分的总和。对于 JJ 深入研究的任何一个方面,任何给定的团队都可以做出合理的选择(尽管可能会错过此处指出的一些极端情况)。由于上下文的要求有限,这种选择甚至可能比本书中提供的建议更适合特定情况。这种方法实现了许多局部最优决策,但全局高度分散,可能会采用多种不同的方法,即使是同一公司内的 API 也是如此。
It’s important to understand the value of this book as more than the sum of its parts. For any one of the aspects J J dives into, any given team could make a reasonable choice (albeit one that might miss some of the corner cases pointed out here). That choice may even be better for that specific situation than the recommendation provided in this book due to the limited requirements of the context. That approach achieves lots of local optimal decisions but a highly fragmented bigger picture, with potentially several different approaches being taken, even by APIs within the same company.
除了针对任何给定问题的一致性之外,本书还提供了跨 API 设计多个领域的一致方法。API 设计者很少有机会深入思考这个问题,我认为自己非常幸运能够与 JJ 和其他人(尤其是 Luke Sneeringer)一起讨论本书中的许多主题。我很高兴谷歌在 API 设计上的投资可以通过本书和 AIP 系统https://aip.dev为其他开发者带来回报。
Beyond consistency for any given problem, this book provides a consistent approach across multiple areas of API design. It’s rare for API designers to be given the space to think deeply about this, and I count myself as very lucky to have worked with J J and others (notably Luke Sneeringer) in discussing many of the topics within the book. I’m thrilled that the investment Google has made in API design can pay dividends to other developers through this book and through the AIP system at https://aip.dev.
虽然我对本书的价值充满信心,但设计出色的 API 并不容易。相反,它消除了许多 API 设计附带的复杂性,让您可以专注于您想要构建的 API 真正独特的方面。您仍然应该期望必须思考,并且努力思考,但要相信这种思考的结果可以成为一个使用起来很愉快的 API。您的用户可能永远不会为此明确感谢您;设计良好的 API 尽管是大量工作的结果,但通常让人感觉很明显。但是您可以在晚上睡个好觉,因为他们不会经历过感觉不太正确的 API 所带来的挫败感,即使它可以正常工作。
While I have great confidence in the value of this book, it doesn’t make it easy to design a great API. Instead, it takes away a lot of the incidental complexity that comes with API design, allowing you to focus on the aspects that are truly unique to the API you want to build. You should still expect to have to think, and think hard, but with confidence that the result of that thinking can be an API that is a joy to work with. Your users may never explicitly thank you for it; a well-designed API often feels obvious despite being the result of huge amounts of toil. But you can sleep well at night knowing that they won’t have experienced the frustrations of an API that doesn’t quite feel right, even when it works.
以本书为脚凳,帮助您的 API 成为一个巨人,为他人提供可以站立的肩膀。
Use this book as a footstool to help your API be a giant that provides shoulders for others to stand on.
—Jon Skeet, Staff Developer Relations Engineer, Google
在学校里,我们学习计算机科学的方式与学习物理定律的方式相同。我们使用 Big-O 表示法分析了运行时间和空间复杂度,了解了各种排序算法的工作原理,并探索了遍历二叉树的不同方法。所以正如你想象的那样,毕业后,我希望我的日常工作本质上主要是科学和数学。但是想象一下当我发现事实并非如此时我的惊讶。
In school, we learned about computer science in the same way that we might learn about the laws of physics. We analyzed run-time and space complexity using Big-O notation, learned how a variety of sorting algorithms worked, and explored the different ways of traversing binary trees. So as you might imagine, after graduating, I expected my day job to be primarily scientific and mathematical in nature. But imagine my surprise when I found that wasn’t the case at all.
事实证明,我要做的大部分工作更多是关于设计、结构和美学,而不是数学和算法。我从来不需要考虑使用哪种排序算法,因为有一个库(通常类似于array.sort())。然而,我确实不得不对我将创建的类、这些类中存在的函数以及每个函数将接受的参数进行长时间的认真思考。而这远比我预想的要困难。
It turned out that most of the work I had to do was more about design, structure, and aesthetics than about mathematics and algorithms. I never needed to think about which sorting algorithm to use because there was a library for that (usually something like array.sort()). I did, however, have to think long and hard about the classes I’d create, the functions that would exist on those classes, and what parameters each function would accept. And this was far more difficult than I expected.
在现实世界中,我了解到完美优化的代码远不如精心设计的代码有价值。事实证明,对于 Web API 来说更是如此,因为它们通常拥有更广泛的受众和更广泛的用例。
In the real world, I learned how perfectly-optimized code is not nearly as valuable as well-designed code. And this turned out to be doubly true for web APIs, as they generally have a far broader audience with a wider variety of use cases.
但这引出了一个问题:“设计良好”的软件意味着什么?什么是“设计良好的 Web API”?在相当长的一段时间里,我不得不依靠大部分随意收集的资源来回答这些问题。对于某些主题,可能会有有趣的博客文章探讨当今使用的一些流行替代方案。对于其他人,Stack Overflow 上可能有一个特别有用的答案可以指导我朝着正确的方向前进。但在许多情况下,有关该主题的材料相对较少,我只能自己想出一个答案,希望我的同事们不要太讨厌它。
But this begs the question: what does it mean for software to be “well-designed”? What is a “well-designed web API”? For quite some time I had to rely on a mostly haphazard collection of resources to answer these questions. For some topics there might be interesting blog posts that explored some of the popular alternatives in use today. For others there might be a particularly useful answer on Stack Overflow that could guide me in the right direction. But in many scenarios, there was relatively little material on the topic in question, and I was left trying to come up with an answer on my own and hoping my colleagues didn’t hate it too much.
经过多年(并随身携带一本封面上写着“可怕的 API 问题”)的笔记本,我终于决定是时候写下我收集到的所有信息并亲眼目睹了。起初,这是我和 Luke Sneeringer 编写的一套 Google 规则,最终成为AIP.dev。但是这些规则读起来有点像法律书;他们说了你应该做什么,但没有说你为什么要那样做。经过大量研究并一遍又一遍地问自己这个确切的问题,这本书在这里展示了这些规则,同时也解释了原因。
After many years of this (and carrying around a notebook with “Scary API Problems” written on the cover), I finally decided that it was time to write down all the information I’d collected and had seen to work first-hand. At first, this was a set of rules for Google that Luke Sneeringer and I codified, which ultimately became AIP.dev. But these rules read sort of like a book of laws; they said what you should do, but didn’t say why you should do it that way. After lots of research and asking myself this exact question over and over, this book is here to present these rules, but also to explain why.
尽管这本书成为世界 API 设计问题的最终解决方案是多么伟大,但遗憾的是我认为情况并非如此。原因很简单:就像建筑一样,任何类型的设计通常都是见仁见智的。这意味着你们中的一些人可能会认为这些指南漂亮而优雅,并将它们用于您未来的所有项目。同时,你们中的一些人可能会认为本书呈现的设计丑陋且限制过度,并以此作为构建 Web API 时不应做的事情的示例。因为我不能让每个人都开心,所以我唯一的目标是提供一套经过实战检验的指导方针,以及对为什么他们看起来像这样的合乎逻辑的解释。
As great as it would be for this book to be the ultimate solution to the world’s API design problems, I sadly don’t think this is the case. The reason for this is simple: much like architecture, any sort of design is generally a matter of opinion. This means that some of you may think these guidelines are beautiful and elegant and use them for all of your projects going forward. At the same time, some of you may think that this book presents designs that are hideous and overly restrictive and use it as an example of what not to do when building a web API. Since I can’t make everyone happy, my only goal is to provide a set of battle-tested guidelines, along with logical explanations for why they look the way they do.
您是否将它们用作遵循或避免的示例取决于您。至少,我希望本书涵盖的主题能引发许多对话,并在这个迷人、复杂且错综复杂的 API 设计世界中引发大量未来工作。
Whether you use them as examples to follow or avoid is up to you. At the very least, I hope the topics covered in this book spark many conversations and quite a lot of future work on this fascinating, complex, and intricate world of API design.
与我的大部分工作一样,这本书是许多不同的人共同贡献的结果。首先,我要感谢我的妻子 Ka-el,在我努力完成这份手稿的最后润色时,她听了我的咆哮和抱怨。如果不是因为她坚定不移的支持,这本书很可能已经被放弃了。此外,许多其他人也扮演过类似的角色,包括克里斯汀·拉涅利、贝基·苏塞尔、珍妮特·克拉克、诺里斯·克拉克、塔吉·克拉克、雪琳·陈、阿斯菲亚·法扎尔和阿达玛·迪亚洛,我非常感谢他们。
As with most of my work, this book is the result of many contributions from many different people. First, I have to thank my wife, Ka-el, for listening to me rant and complain while struggling with the finishing touches of this manuscript. There’s a good chance that this book may have been abandoned had it not been for her unwavering support. Additionally, many others have played a similar role, including Kristen Ranieri, Becky Susel, Janette Clarke, Norris Clarke, Tahj Clarke, Sheryn Chan, Asfia Fazal, and Adama Diallo, to whom I’m very grateful.
一个由 API 爱好者组成的核心团队在审查和讨论本书涵盖的主题以及提供高级指导方面发挥了重要作用。我要特别感谢 Eric Brewer、Hong Zhang、Luke Sneeringer、Jon Skeet、Alfred Fuller、Angie Lin、Thibaud Hottelier、Garrett Jones、Tim Burks、Mak Ahmad、Carlos O'Ryan、Marsh Gardiner、Mike Kistler、Eric Wheeler 、Max Ross、Marc Jacobs、Jason Woodard、Michael Rubin、Milo Martin、Brad Meyers、Sam McVeety、Rob Clevenger、Mike Schwartz、Lewis Daly、Michael Richards 和 Brian Grant 多年来提供的所有帮助。
A core team of API enthusiasts were instrumental in reviewing and debating the topics covered in this book, as well as providing high-level guidance. In particular, I want to thank Eric Brewer, Hong Zhang, Luke Sneeringer, Jon Skeet, Alfred Fuller, Angie Lin, Thibaud Hottelier, Garrett Jones, Tim Burks, Mak Ahmad, Carlos O’Ryan, Marsh Gardiner, Mike Kistler, Eric Wheeler, Max Ross, Marc Jacobs, Jason Woodard, Michael Rubin, Milo Martin, Brad Meyers, Sam McVeety, Rob Clevenger, Mike Schwartz, Lewis Daly, Michael Richards, and Brian Grant for all of their help over the years.
许多其他人通过他们自己的独立工作间接地为本书做出了贡献,为此我必须感谢 Roy Fielding、“四人帮”(Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides)、Sanjay Ghemawatt、Urs Hoelzle、Andrew菲克斯、肖恩·昆兰和拉里·格林菲尔德。我还要感谢 Stu Feldman、Ari Balogh、Rich Sanzi、Joerg Heilig、Eyal Manor、Yury Izrailevsky、Walt Drummond、Caesar Sengupta 和 Patrick Teo 在 Google 探索这些主题时给予的支持和指导。
Many others contributed to this book indirectly with their own independent work, and for that I must thank Roy Fielding, the “gang of four” (Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides), Sanjay Ghemawatt, Urs Hoelzle, Andrew Fikes, Sean Quinlan, and Larry Greenfield. I’d also like to thank Stu Feldman, Ari Balogh, Rich Sanzi, Joerg Heilig, Eyal Manor, Yury Izrailevsky, Walt Drummond, Caesar Sengupta, and Patrick Teo for their support and guidance while exploring these topics at Google.
特别感谢 Dave Nagle,他支持我在广告、云、API 等领域的所有工作,并鼓励我超越自己的舒适区。还要感谢 Mark Chadwick,他在 10 多年前帮助我克服了 API 设计方面的冒名顶替综合症。他的建设性反馈和客气话是我决定深入研究计算机科学这一有趣领域的重要原因。此外,要特别感谢 Mark Hammond,是他首先教会我质疑一切,即使是在不舒服的时候。
A special thanks to Dave Nagle for being a champion for all my work in ads, the cloud, APIs, and beyond and for encouraging me to push myself beyond my comfort zone. And thanks to Mark Chadwick who, over 10 years ago, helped me get past my imposter syndrome in API design. His constructive feedback and kind words are a big part of why I decided to dive deeper into this interesting area of computer science. Additionally, a special thank you is owed to Mark Hammond, who first taught me to question everything, even when it’s uncomfortable.
如果没有 Manning 的编辑团队,这个项目是不可能完成的,特别是 Mike Stephens 和 Marjan Bace,他们认可了本书的最初想法,还有 Christina Taylor 在另一个长期项目中一直和我在一起。我也感谢 Al Krinker 详细的章节评论;我的项目编辑 Deirdre Hiam;文字编辑,Michele Mitchell;校对员,Keri Hales;和审稿编辑 Ivan Martinović。感谢 Manning 的所有帮助实现这一目标的人。
This project would not have been possible without the editorial team at Manning, in particular Mike Stephens and Marjan Bace who okay’d the initial idea for this book, and Christina Taylor who stuck with me for another long-term project. I am also grateful for the detailed chapter reviews by Al Krinker; my project editor, Deirdre Hiam; copyeditor, Michele Mitchell; proofreader, Keri Hales; and reviewer editor, Ivan Martinović. Thank you to all those at Manning who helped make this happen.
致所有审稿人:Akshat Paul、Anthony Cramp、Brian Daley、Chris Heneghan、Daniel Bretoi、David J. Biesack、Deniz Vehbi、Gerardo Lecaros、Jean Lazarou、John C. Gunvaldson、Jorge Ezequiel Bo、Jort Rodenburg、Luke Kupka、Mark Nenadov、Rahul Rai、Richard Young、Roger Dowell、Ruben Vandeginste、Satej Kumar Sahu、Steven Smith、Yul Williams、Yurii Bodarev 和 Zoheb Ainapore,你们的建议帮助本书变得更好。
To all the reviewers: Akshat Paul, Anthony Cramp, Brian Daley, Chris Heneghan, Daniel Bretoi, David J. Biesack, Deniz Vehbi, Gerardo Lecaros, Jean Lazarou, John C. Gunvaldson, Jorge Ezequiel Bo, Jort Rodenburg, Luke Kupka, Mark Nenadov, Rahul Rai, Richard Young, Roger Dowell, Ruben Vandeginste, Satej Kumar Sahu, Steven Smith, Yul Williams, Yurii Bodarev, and Zoheb Ainapore, your suggestions helped make this a better book.
API 设计模式旨在为构建 Web API 提供一组安全、灵活、可重用的模式。它首先涵盖一些通用设计原则,并以此为基础展示一组设计模式,旨在为构建 API 时的常见场景提供简单的解决方案。
API Design Patterns was written to provide a collection of safe, flexible, reusable patterns for building web APIs. It starts by covering some general design principles and builds on these to showcase a set of design patterns that aim to provide simple solutions to common scenarios when building APIs.
API 设计模式适用于正在构建或计划构建 Web API 的任何人,尤其是当该 API 将向公众公开时。熟悉一些序列化格式(例如,JSON、Google Protocol Buffers 或 Apache Thrift)或常见的存储范例(例如,关系数据库模式)当然很好,但绝不是必需的。如果您已经熟悉 HTTP 及其各种方法(例如,GET和POST),这也是一个不错的收获,因为它是贯穿本书示例的首选传输方式。如果您发现自己在设计一个 API 并遇到问题并思考,“我相信一定有人已经解决了这个问题,”这本书适合您。
API Design Patterns is for anyone who is building or plans to build a web API, particularly when that API will be exposed to the public. Familiarity with some serialization formats (e.g., JSON, Google Protocol Buffers, or Apache Thrift) or common storage paradigms (e.g., relational database schemas) is certainly nice to have but is in no way required. It’s also a nice bonus if you’re already familiar with HTTP and its various methods (e.g., GET and POST), as it is the transport of choice throughout the examples in this book. If you find yourself designing an API and running into problems and thinking, “I’m sure someone must have figured this out already,” this book is for you.
本书分为六个部分,前两部分涵盖了 API 设计中更一般的主题,接下来的四部分专门介绍了设计模式本身。第 1 部分首先为本书的其余部分奠定了基础,并为 Web API 本身以及我们将来将应用于这些 Web API 的设计模式提供了一些定义和评估框架。
This book is divided into six parts, with the first two parts covering more general topics in API design and the next four dedicated to the design patterns themselves. Part 1 opens by setting the stage for the rest of the book and providing some definitions and evaluative frameworks for web APIs themselves and the design patterns that we’ll apply to those web APIs in the future.
Chapter 1 starts by defining what we mean by an API and why APIs are important. It also provides a framework of sorts for how we can evaluate how good an API really is.
第 2 章扩展了第 1 章,探讨了如何将设计模式应用于 API 并解释它们如何对构建它们的任何人有用。它涵盖了 API 设计模式的剖析,以及一个简短的案例研究,说明使用其中一种设计模式如何能够带来整体更好的 API。
Chapter 2 expands on chapter 1 by looking at how design patterns can be applied to APIs and explaining how they can be useful to anyone building them. It covers the anatomy of an API design pattern as well as a short case study of how using one of these design patterns can lead to an overall better API.
第 2 部分旨在通过概述构建任何 API 时应考虑的一些通用设计原则,进一步构建第 1 部分中建立的阶段。
Part 2 aims to further build on the stage established in part 1 by outlining some general design principles that should be considered when building any API.
第 3 章介绍了我们可能需要在 API 中命名的所有不同组件,以及在为它们选择名称时需要考虑的因素。它还显示了命名是如何至关重要的,尽管它看起来很肤浅。
Chapter 3 looks at all the different components we might need to name in an API and what to take into consideration when choosing names for them. It also shows how naming is critically important despite seeming like something superficial.
第 4 章深入探讨更大的 API,其中我们可能拥有多个相互关联的资源。在讨论了一些在决定资源及其关系时要问的问题之后,它最后介绍了一些要避免的事情的例子。
Chapter 4 digs deeper into larger APIs, where we might have multiple resources that relate to one another. After going through some questions to ask when deciding on the resources and their relationships, it finishes by covering some examples of things to avoid.
第 5 章探讨了如何在 API 中使用不同的数据类型和这些数据类型的默认值。它涵盖了最常见的数据类型,如字符串和数字,以及更复杂的可能性,如地图和列表。
Chapter 5 explores how different data types and the default values for these data types should be used in an API. It covers the most common data types such as strings and numbers, as well as the more complex possibilities like maps and lists.
第 3 部分标志着设计模式目录的开始,从应该适用于几乎所有 API 的基本模式开始。
Part 3 marks the beginning of the design pattern catalog, starting with the fundamental patterns that should apply to almost all APIs.
第 6 章仔细研究 API 用户如何识别资源,深入挖掘标识符的低级细节,例如逻辑删除、字符集和编码,并使用校验和区分缺失 ID 和无效 ID。
Chapter 6 looks closely at how resources can be identified by users of an API, digging into the low-level details of identifiers such as tombstoning, character set and encodings, and using checksums to distinguish between missing versus invalid IDs.
第 7 章详细概述了 Web API 的不同标准方法(get、list、create、update 和 delete)应该如何工作。它还解释了为什么每个标准方法在所有资源中以完全相同的方式运行而不是改变以适应每个资源的独特方面如此重要。
Chapter 7 outlines in great detail how the different standard methods of web APIs (get, list, create, update, and delete) should work. It also explains why it’s so important that each standard method behave in exactly the same way across all the resources rather than varying to accommodate the unique aspects of each resource.
第 8 章扩展了两个特定的标准方法(get 和 update)来说明用户如何与资源的一部分而不是整个资源进行交互。它解释了为什么这是必要和有用的(对于用户和 API),以及如何保持对该功能的支持尽可能减少侵入性。
Chapter 8 expands on two specific standard methods (get and update) to address how users can interact with parts of a resource rather than the entire thing. It explains why this is necessary and useful (for both users and the API), as well as how to keep support for this functionality as minimally intrusive as possible.
第 9 章超越了标准方法,并为我们使用自定义方法在 API 中可能需要的任何类型的操作打开了大门。特别强调解释自定义方法何时有意义(何时不有意义),以及如何在您自己的 API 中做出此决定。
Chapter 9 pushes past standard methods and opens the door to any sort of action we might want in an API using custom methods. Special emphasis is given to explain when custom methods make sense (and when they don’t), as well as how to make this decision in your own API.
第 10 章探讨了 API 方法可能不是瞬时的独特场景,以及如何以方便的方式为具有长时间运行操作 (LRO) 的用户提供支持。它探讨了 LRO 的工作原理以及 LRO 可以支持的所有方法,包括暂停、恢复和取消长时间运行的工作。
Chapter 10 explores the unique scenario where API methods may not be instantaneous and how to support this in a convenient way for users with long-running operations (LROs). It explores how LROs work and all the methods that can be supported by LROs, including pausing, resuming, and canceling the long-running work.
第 11 章介绍了反复执行工作的概念,有点像 Web API 的 cron 作业。它解释了如何使用Execution资源并按计划或按需运行这些资源。
Chapter 11 covers the concept of work that gets executed over and over, sort of like cron jobs for web APIs. It explains how to use Execution resources and run these on either a schedule or on demand.
第 4 部分侧重于资源以及它们如何相互关联,有点像对第 4 章的更广泛的探索。
Part 4 focuses on resources and how they relate to one another, sort of like a more expansive exploration of chapter 4.
Chapter 12 explains how small, isolated bits of related data might be segregated into singleton sub-resources. It goes into detail about the circumstances for when this is a good idea as well as when it’s not.
第 13 章概述了 Web API 中的资源应如何使用引用指针或内联值存储对其他资源的引用。它还解释了如何处理边缘情况行为,例如随着引用数据随时间的变化而级联删除或更新。
Chapter 13 outlines how resources in a web API should store references to other resources with either reference pointers or in-lined values. It also explains how to handle edge-case behaviors such as cascading deletes or updates as referenced data changes over time.
第 14 章扩展了资源之间的一对一关系,并解释了如何使用关联资源来表示多对多关系。它还涵盖了如何存储关于这些关系的元数据。
Chapter 14 expands on one-to-one relationships between resources and explains how to use association resources to represent many-to-many relationships. It also covers how metadata can be stored about these relationships.
第 15 章着眼于在处理多对多关系时使用添加和删除快捷方法作为依赖关联资源的替代方法。它还涵盖了使用这些方法时的一些权衡,以及为什么它们可能并不总是理想的选择。
Chapter 15 looks at using add and remove shortcut methods as an alternative to relying on association resources when handling many-to-many relationships. It also covers some of the trade-offs when using these methods and why they might not always be the ideal fit.
第 16 章着眼于多态性的复杂概念,其中变量可以采用各种不同的类型。它涵盖了如何处理 API 资源上的多态字段以及为什么应避免使用多态方法。
Chapter 16 looks at the complex concept of polymorphism, where variables can take on a variety of different types. It covers how to handle polymorphic fields on API resources as well as why polymorphic methods should be avoided.
第 5 部分不再局限于一次涉及单个 API 资源的交互,而是开始研究旨在与整个资源集合进行交互的 API 设计模式。
Part 5 moves beyond interactions involving a single API resource at a time and begins looking at API design patterns targeted at interacting with entire collections of resources.
第 17 章解释了如何在 API 中复制或移动资源。它解决了一些细微的复杂问题,例如处理外部数据、从不同父级继承的元数据以及应如何处理子资源。
Chapter 17 explains how resources can be copied or moved in an API. It addresses the nuanced complications such as handling external data, inherited metadata from a different parent, and how child resources should be treated.
第 18 章探讨了如何调整标准方法(get、create、update 和 delete)以对资源集合而不是一次对单个资源进行操作。它还涵盖了一些棘手的部分,例如应如何返回结果以及如何处理部分失败。
Chapter 18 explores how to adapt the standard methods (get, create, update, and delete) to operate on a collection of resources rather than a single resource at a time. It also covers some of the tricky pieces, such as how results should be returned and how to handle partial failures.
第 19 章扩展了第 17 章中批量删除方法的思想,以删除匹配特定过滤器而不仅仅是特定标识符的资源。它还探讨了如何解决一致性问题和最佳实践,以避免意外破坏数据。
Chapter 19 expands on the idea of the batch delete method from chapter 17 to remove resources that match a specific filter rather than just a specific identifier. It also explores how to address issues of consistency and best practices to avoid accidental destruction of data.
第 20 章仔细研究了本身不可直接寻址的非资源数据的摄取。它涵盖了如何使用匿名写入以及一致性主题以及这种匿名数据摄取何时适合 API 的权衡。
Chapter 20 looks closely at ingestion of non-resource data that, itself, is not directly addressable. It covers how to use anonymous writes as well as topics of consistency and the trade-offs of when this type of anonymous data ingestion is a good fit for an API.
第 21 章解释了如何使用分页处理浏览大型数据集,依靠不透明的页面标记来遍历数据。它还演示了如何在单个大型资源中使用分页。
Chapter 21 explains how to handle browsing large data sets using pagination, relying on opaque page tokens to iterate through data. It also demonstrates how to use pagination inside single large resources.
第 22 章着眼于如何处理将过滤条件应用于列表资源以及在 API 中表示这些过滤器的最佳方式。这直接适用于第 19 章中涵盖的主题。
Chapter 22 looks at how to handle applying filter criteria to listing resources and the best way to represent these filters in APIs. This applies directly to the topics covered in chapter 19.
Chapter 23 explores how to handle importing and exporting resources in and out of an API. It also dives into the nuanced differences between import and export operations as compared to backup and restore.
第 6 部分重点介绍 API 中不太令人兴奋的安全领域。这意味着要确保 API 免受攻击者的侵害,同时确保所提供的 API 方法免受用户自身错误的侵害。
Part 6 focuses on the somewhat less exciting areas of safety and security in APIs. This means ensuring that APIs are safe from attackers but also that the API methods provided are made safe from users’ own mistakes.
第 24 章探讨了版本控制主题以及不同版本相互兼容的意义。它深入探讨了兼容性作为一个范围的概念,以及在 API 中保持一致的兼容性策略定义的重要性。
Chapter 24 explores the topic of versioning and what it means for different versions to be compatible with one another. It digs into the idea of compatibility as a spectrum and the importance of a compatibility policy definition that is consistent across an API.
第 25 章通过提供一种模式(软删除)来开始保护用户免受自身侵害的工作,该模式允许资源从视图中删除而不是从系统中完全删除。
Chapter 25 begins the work of protecting users from themselves by providing a pattern (soft deletion) for allowing resources to be removed from view while not being completely deleted from the system.
第 26 章尝试使用请求标识符保护系统免受重复操作。它探讨了使用请求 ID 的缺陷以及确保在大型系统中正确处理这些 ID 的算法。
Chapter 26 attempts to protect the system from duplicate actions using request identifiers. It explores the pitfalls of using request IDs as well as an algorithm to ensure that these IDs are handled properly in large-scale systems.
第 27 章重点介绍验证请求,这些请求允许用户在不执行底层操作的情况下预览 API 中的操作。它还探讨了如何处理更高级的主题,例如实时请求和验证请求期间的副作用。
Chapter 27 focuses on validation requests that allow users to get a preview of an action in an API without executing the underlying operation. It also explores how to handle more advanced topics such as side effects during both live requests and validation requests.
第 28 章介绍了资源修订的概念,作为一种随时间跟踪变化的方法。它还涵盖了基本操作,例如恢复到以前的修订,以及更高级的主题,例如如何将修订应用到层次结构中的子资源。
Chapter 28 introduces the idea of resource revisions as a way of tracking changes over time. It also covers the basic operations, such as restoring to previous revisions, and more advanced topics, such as how to apply revisions to child resources in the hierarchy.
第 29 章介绍了一种模式,用于在应重试 API 请求时通知用户。它还包括有关不同 HTTP 响应代码以及重试它们是否安全的指南。
Chapter 29 presents a pattern for informing users when API requests should be retried. It also includes guidelines about different HTTP response codes and whether they are safe to be retried.
第 30 章探讨了对单个请求进行身份验证的主题,以及在 API 中对用户进行身份验证时要考虑的不同安全标准。它提出了一种用于对 API 请求进行数字签名的规范,该规范遵循安全最佳实践,以确保 API 请求具有可验证的来源和完整性,并且以后不能被拒绝。
Chapter 30 explores the topic of authenticating individual requests and the different security criteria to be considered when authenticating users in an API. It presents a specification for digitally signing API requests that adheres to security best practices to ensure API requests have verifiable origin and integrity and are not able to be repudiated later.
本书包含许多源代码示例,既有编号的清单,也有与普通文本一致的代码。在这两种情况下,源代码都被格式化为 afixed-width font like this以将其与普通文本分开。有时代码也会bold突出显示与本章前面的步骤相比发生变化的代码,例如当新功能添加到现有代码行时。
This book contains many examples of source code both in numbered listings and in line with normal text. In both cases, source code is formatted in a fixed-width font like this to separate it from ordinary text. Sometimes code is also in bold to highlight code that has changed from previous steps in the chapter, such as when a new feature adds to an existing line of code.
在许多情况下,原始源代码已被重新格式化;我们添加了换行符并修改了缩进以适应书中可用的页面空间。在极少数情况下,这还不够,列表中包含续行标记 ( ➥ )。此外,当在文本中描述代码时,源代码中的注释通常会从列表中删除。许多清单都附有代码注释,突出了重要的概念。
In many cases, the original source code has been reformatted; we’ve added line breaks and reworked indentation to accommodate the available page space in the book. In rare cases, even this was not enough, and listings include line-continuation markers (➥). Additionally, comments in the source code have often been removed from the listings when the code is described in the text. Code annotations accompany many of the listings, highlighting important concepts.
在与我们的早期读者和审查团队进行了大量讨论之后,出于各种原因,我决定使用 TypeScript 作为标准语言。首先,对于既熟悉动态语言(如 JavaScript 或 Python)又熟悉静态语言(如 Java 或 C++)的人来说,TypeScript 很容易上手。此外,虽然它可能不是每个人都喜欢,也不是所有读者都能立即编写自己的 TypeScript 代码,但代码片段可以被视为伪代码,大多数软件开发人员应该能够辨别其含义。
After quite a bit of discussion with our early readers and review team, I’ve decided to use TypeScript as the standard language for a variety of reasons. First, TypeScript is easy to follow for anyone familiar with both dynamic languages (like JavaScript or Python) as well as static languages (like Java or C++). Additionally, while it might not be everyone’s favorite, and not all readers will be able to write their own TypeScript code right away, the code snippets can be treated as pseudo code, and most software developers should be able to discern the meaning.
在使用 TypeScript 定义 API 时,有两个部分需要解决:资源和方法。对于前者,TypeScript 的原语(例如,接口)在为 API 资源定义模式时非常具有表现力,使 API 定义足够短,几乎总是可以放在几行中。因此,所有 API 资源都被定义为 TypeScript 接口,这具有使 JSON 表示非常明显的额外好处。
When it comes to defining APIs using TypeScript, there are two pieces to address: resources and methods. For the former, TypeScript’s primitives (e.g., interfaces) are quite expressive when defining schemas for API resources, keeping the API definitions short enough that they almost always fit in just a few lines. As a result, all API resources are defined as TypeScript interfaces, which has the added bonus of making the JSON representation quite obvious.
对于 API 方法,问题有点复杂。在这种情况下,我选择使用 TypeScript 抽象类来表示总体 API 本身,并使用抽象函数来定义 API 方法,遵循 Google 的 Protocol Buffers 的 RPC 常用的约定。这提供了仅定义 API 方法的能力,而不必担心底层实现。
For API methods, the problem is a bit more complicated. In this case, I’ve opted to use TypeScript abstract classes to represent the overarching API itself, with abstract functions to define the API methods, following a convention commonly used with Google’s Protocol Buffers’ RPCs. This provides the ability to define just the API methods without having to worry about the underlying implementations.
在考虑 API 方法的输入和输出时,我决定再次依赖 Protocol Buffers 来获得灵感,从请求和响应接口的角度来思考。这意味着在大多数情况下,将有表示这些输入和输出的接口,命名为带有-Request或-Response后缀的 API 方法(例如,CreateChatRoomRequest用于CreateChatRooomAPI 方法)。
When considering the inputs and outputs of API methods, I’ve decided to rely again on Protocol Buffers for inspiration, thinking in terms of request and response interfaces. This means that in most cases there will be interfaces representing these inputs and outputs, named as the API method with a -Request or -Response suffix (e.g., CreateChatRoomRequest for a CreateChatRooom API method).
最后,由于本书在很大程度上依赖于 RESTful 概念,因此必须有一种方法将这些 RPC 映射到 URL(和 HTTP 方法)。为此,我选择使用 TypeScript 装饰器作为各种 API 方法的注释,每个不同的 HTTP 方法(例如 , , @get)都有一个装饰器。为了指示 API 方法应映射到的 URL 路径,每个装饰器都接受一个模板字符串,它还支持请求接口中变量的通配符。例如,将填充请求中的 ID 字段。在这种情况下,星号表示任何值的占位符,不包括正斜杠字符。@post@delete@get("/{id=chatRooms/*}")
Finally, since this book relies quite a lot on RESTful concepts, there had to be a way of mapping these RPCs to a URL (and HTTP method). For this, I’ve chosen to use TypeScript decorators as annotations on the various API methods, with one decorator for each of the different HTTP methods (e.g., @get, @post, @delete). To indicate the URL path that the API method should map to, each decorator accepts a template string, which also supports wildcards for variables in the request interface. For example, @get("/{id=chatRooms/*}") would populate an ID field on the request. In this case, the asterisk indicates a placeholder for any value excluding a forward slash character.
尽管所有这些设计模式都依赖 OpenAPI 规范,但有一些问题往往会对本书的读者造成伤害。首先,OpenAPI 规范主要供计算机使用(例如,代码生成器、文档呈现等)。由于本书的目标是向其他 API 设计者传达复杂的 API 设计主题,因此 OpenAPI 似乎并不是实现该目标的最佳选择。
As great as it would have been to rely on OpenAPI specifications for all these design patterns, there are a few issues that tended to do a disservice to readers of this book. First, OpenAPI specifications are intended for consumption primarily by computers (e.g., code generators, documentation renders, etc.). Since the goal of this book is to communicate complicated API design topics to other API designers, OpenAPI just didn’t seem like the best option available for that goal.
其次,虽然 OpenAPI 是一个了不起的项目,但无论我们以 YAML 还是 JSON 格式表示 API,它都非常冗长。不幸的是,使用 OpenAPI 表示这些复杂的主题是完全可能的,但这不是最简洁的选择,这会导致相当多的额外内容而没有增加那么多的价值。
Secondly, while OpenAPI is an amazing project, it is quite verbose regardless of whether we represent APIs in YAML or JSON format. Unfortunately, representing these complicated topics using OpenAPI would have been completely possible, but not the most concise choice, leading to quite a lot of extra content without adding as much value.
最后,在 OpenAPI、Protocol Buffers 和 TypeScript 之间,早期的读者和评论者给出了非常明确的反馈,即 TypeScript 选项最适合这个特定的用例。请记住,我并不是在提倡人们使用 TypeScript 来定义他们的 API。它非常适合这个项目。
In the end, between OpenAPI, Protocol Buffers, and TypeScript, the early readers and reviewers gave pretty clear feedback that the TypeScript option was the best fit for this particular use case. Keep in mind that I’m not at all advocating people use TypeScript for defining their APIs. It was just a very good fit for this project.
购买API Design Patterns包括免费访问由 Manning Publications 运营的私人网络论坛,您可以在其中对本书发表评论、提出技术问题并获得作者和其他用户的帮助。要访问论坛,请转到https://livebook.manning.com/book/api-design-patterns/welcome/v-7。您还可以在https://livebook.manning.com/#!/discussion上了解有关 Manning 论坛和行为规则的更多信息。
Purchase of API Design Patterns includes free access to a private web forum run by Manning Publications where you can make comments about the book, ask technical questions, and receive help from the author and from other users. To access the forum, go to https://livebook.manning.com/book/api-design-patterns/welcome/v-7. You can also learn more about Manning’s forums and the rules of conduct at https://livebook .manning.com/#!/discussion.
Manning 对我们的读者的承诺是提供一个场所,让各个读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与任何特定数量的承诺,他们对论坛的贡献仍然是自愿的(并且是无偿的)。我们建议您尝试向作者提出一些具有挑战性的问题,以免他的兴趣发生偏差!只要本书还在印刷,就可以从出版商的网站访问论坛和以前讨论的档案。
Manning’s commitment to our readers is to provide a venue where a meaningful dialogue between individual readers and between readers and the author can take place. It is not a commitment to any specific amount of participation on the part of the author, whose contribution to the forum remains voluntary (and unpaid). We suggest you try asking the author some challenging questions lest his interest stray! The forum and the archives of previous discussions will be accessible from the publisher’s website as long as the book is in print.
有关 API 设计主题的进一步阅读,请参阅https://aip.dev,其中非常详细地涵盖了许多类似的主题。
For further reading on API design topics, see https://aip.dev, which covers many similar topics in quite a lot of detail.
JJ Geewax是谷歌的一名软件工程师,专注于实时支付系统、云基础设施和 API 设计。他还是Google Cloud Platform in Action的作者和 AIP.dev 的联合创始人,AIP.dev 是从 Google 发起的 API 设计标准的全行业合作组织。他与妻子 Ka-el 和儿子 Luca 住在新加坡。
J J Geewax is a software engineer at Google, focusing on real-time payment systems, cloud infrastructure, and API design. He is also the author of Google Cloud Platform in Action and the cofounder of AIP.dev, an industry-wide collaboration on API design standards started at Google. He lives in Singapore with his wife, Ka-el, and son, Luca.
API Design Patterns封面上的图片标题为Marchand d'Estampes à Vienne或“维也纳版画商人”。这幅插图取自 Jacques Grasset de Saint-Sauveur(1757-1810 年)收集的各国服饰,名为《Costumes de Différents Pays》,于 1797 年在法国出版。每幅插图均由手工精心绘制和上色。Grasset de Saint-Sauveur 丰富多样的藏品生动地提醒我们 200 年前世界城镇和地区在文化上的差异。人们彼此隔绝,说着不同的方言和语言。无论是在大街上还是在乡下,仅凭着装就很容易辨别出他们住在哪里,他们的行业或生活地位。
The figure on the cover of API Design Patterns is captioned Marchand d’Estampes à Vienne, or “Merchant of Prints in Vienna.” The illustration is taken from a collection of dress costumes from various countries by Jacques Grasset de Saint-Sauveur (1757–1810), titled Costumes de Différents Pays, published in France in 1797. Each illustration is finely drawn and colored by hand. The rich variety of Grasset de Saint-Sauveur’s collection reminds us vividly of how culturally apart the world’s towns and regions were just 200 years ago. Isolated from each other, people spoke different dialects and languages. In the streets or in the countryside, it was easy to identify where they lived and what their trade or station in life was just by their dress.
从那时起,我们的着装方式发生了变化,当时如此丰富的地区多样性已经消失。现在很难区分不同大陆的居民,更不用说不同的城镇、地区或国家了。也许我们已经用文化多样性换取了更加多样化的个人生活——当然是为了更加多样化和快节奏的技术生活。
The way we dress has changed since then, and the diversity by region, so rich at the time, has faded away. It is now hard to tell apart the inhabitants of different continents, let alone different towns, regions, or countries. Perhaps we have traded cultural diversity for a more varied personal life—certainly for a more varied and fast-paced technological life.
在这个很难区分计算机书籍的时代,Manning 以两个世纪前区域生活的丰富多样性为基础,由 Grasset de Saint-索沃尔的照片。
At a time when it is hard to tell one computer book from another, Manning celebrates the inventiveness and initiative of the computer business with book covers based on the rich diversity of regional life of two centuries ago, brought back to life by Grasset de Saint-Sauveur’s pictures.
API 设计很复杂毕竟,如果这很容易,那么可能根本不需要这本书。但在我们开始探索使 API 设计更易于管理的工具和模式之前,我们首先必须就一些基本术语以及对本书的期望达成一致。在接下来的两章中,我们将介绍一些介绍性材料,这些材料将作为我们阅读本书其余部分的平台。
API design is complicated. After all, if it were easy there would probably be little need for this book at all. But before we start exploring the tools and patterns to make API design a bit more manageable, we first have to agree on some fundamental terminology and what to expect from this book. In the next two chapters, we’ll cover some of the introductory material that will act as a platform for us to build on throughout the rest of the book.
我们将从第 1 章开始,详细定义 API 的含义。更重要的是,我们将研究好的 API 是什么样的,以及如何将它们与坏的 API 区分开来。然后在第 2 章中,我们将更仔细地了解设计模式的含义以及本书其余部分概述的模式剖析,最终目标是依靠这些设计模式来构建始终如一的良好 API。
We’ll start in chapter 1 by defining in detail what we mean by an API. More importantly, we’ll investigate what good APIs look like and how to distinguish them from bad APIs. Then in chapter 2, we’ll look more closely at what we mean by a design pattern and the anatomy of the patterns outlined in the rest of the book, with the end goal of relying on these design patterns to build consistently good APIs.
很可能通过阅读本书,您已经熟悉了 API 的高级概念。此外,您可能已经知道 API 代表应用程序a编程p接口i,因此本章的重点将更详细地介绍这些基础知识的实际含义,以及它们的重要性。让我们从更仔细地研究 API 的概念开始。
Chances are that by picking up this book you’re already familiar with the high-level concept of an API. Additionally, you probably already know that API stands for application programming interface, so the focus of this chapter will cover what these basics actually mean in more detail, as well as why they matter. Let’s start by looking more closely at this idea of an API.
一个API 定义了计算机系统交互的方式。由于极少数系统生活在真空中,因此 API 无处不在也就不足为奇了。我们可以从语言包管理器(例如,提供类似方法的加密库function encrypt(input: string): string)和我们自己编写的代码中技术上找到 API,即使它从未打算供其他任何人使用。但是有一种特殊类型的 API 被构建为通过网络公开并由许多不同的人远程使用,而这些类型正是本书的重点,通常称为“Web API”。
An API defines the way in which computer systems interact. And since an exceptionally small number of systems live in a vacuum, it should come as no surprise that APIs are everywhere. We can find APIs in the libraries we use from language package managers (e.g., an encryption library that provides a method like function encrypt(input: string): string) and technically in the code we write ourselves, even if it’s never intended for use by anyone else. But there’s one special type of API that is built to be exposed over a network and used remotely by lots of different people, and it’s these types that are the focus of this book, often called “web APIs.”
Web API 在很多方面都很有趣,但这一特殊类别最有趣的方面可以说是那些构建 API 的人拥有如此多的控制权,而那些使用 Web API 的人则相对较少。当我们使用一个库时,我们处理的是库本身的本地副本,这意味着那些构建 API 的人可以随时随地做他们想做的事,而不会伤害用户。Web API 是不同的,因为没有副本。相反,当 Web API 的构建者进行更改时,无论用户是否提出要求,这些更改都会强加给用户。
Web APIs are interesting in many ways, but the most interesting aspect of this special category is arguably the fact that those building the API have so much control while those using web APIs have relatively little. When we use a library, we deal in local copies of the library itself, meaning those building the API can do whatever they want, whenever they want without the possibility of harming users. Web APIs are different because there are no copies. Instead, when the builders of a web API make changes, these changes are forced on users whether they ask for them or not.
例如,想象一个允许您加密数据的 Web API 调用。如果使用此 API 的团队决定在加密数据时使用不同的算法,那么您实际上别无选择。当调用加密方法时,您的数据将使用最新的算法进行加密。在一个更极端的例子中,团队可能决定完全关闭 API 并忽略您的请求。到那时,您的应用程序将突然停止工作,您对此无能为力。这两种情况如图 1.1 所示。
For example, imagine a web API call that allows you to encrypt data. If the team that works on this API decides to use a different algorithm when encrypting your data, you don’t really have a choice in the matter. When calling the encryption method, your data will be encrypted with the latest algorithm. In a more extreme example, the team could decide to shut off the API entirely and ignore your requests. At that point, your application will suddenly stop working and there’s not much you can do about it. Both of these scenarios are shown in figure 1.1.
Figure 1.1 Possible consumer-facing experiences when dealing with a web API
然而,对于消费者来说,Web API 的缺点通常是那些构建 API 的人的主要好处:他们能够保持对 API 的完全控制。例如,如果加密 API 使用了一种超级机密的新算法,那么构建它的团队可能不想以库的形式将代码公开给全世界。相反,他们可能更愿意使用 Web API,这将允许他们公开功能在不泄露其宝贵知识产权的情况下使用超级机密算法。其他时候,一个系统可能需要非凡的计算能力,如果将其部署为图书馆并在家用电脑或笔记本电脑上运行,这将需要很长时间才能运行。在这些情况下,例如使用许多机器学习 API,构建 Web API 允许您公开强大的功能,同时向消费者隐藏计算需求,如图 1.2 所示。
However, the drawbacks of a web API for consumers are often the primary benefits for those building the APIs: they’re able to maintain complete control of the API. For example, if the encryption API used a super-secret new algorithm, the team that built it would probably not want to just give that code away to the world in the form of a library. Instead, they’d probably prefer to use a web API, which would allow them to expose the functionality of the super-secret algorithm without giving away their valuable intellectual property. Other times, a system might require extraordinary computational power, which would take way too long to run if it was deployed as a library and run on a home computer or laptop. In those cases, such as with many machine learning APIs, building a web API allows you to expose the powerful functionality while hiding the computational requirements from consumers, shown in figure 1.2.
Figure 1.2 An example of a web API hiding the computational power needed
现在我们了解了什么是 API(尤其是 Web API),这就提出了一个问题:为什么它们重要?
Now that we understand what APIs (and specifically web APIs) are, this raises the question: why do they matter?
它是专门为人类使用而设计和构建的软件并不少见,这从根本上没有错。然而,在过去的几年里,我们看到越来越多的人关注自动化,我们的目标是构建计算机程序来做我们人类所做的事情,而且速度更快。不幸的是,正是在这一点上,“仅限人类”的软件成为了一个问题。
It’s not uncommon for software to be designed and built for human use exclusively, and there’s nothing fundamentally wrong with this. However, over the past several years we’ve seen more and more focus on automation, where we aim to build computer programs that do what we humans do, only faster. Unfortunately, it’s at this point that the “human-only” software becomes a bit of a problem.
当我们设计专门供人类使用的东西时,我们的交互涉及鼠标和键盘,我们倾向于将系统的布局和视觉方面与原始数据和功能方面混为一谈。这是一个问题,因为很难向计算机解释如何与图形界面交互。而且这个问题变得更糟,因为改变程序的视觉方面可能还需要我们重新教计算机如何与这个新的图形界面交互。实际上,虽然变化对我们来说可能只是装饰性的,但它们对计算机来说是完全无法识别的。换句话说,对于计算机来说,不存在“仅仅是装饰品”这样的东西。
When we design something exclusively for human use, where our interactions involve a mouse and keyboard, we tend to conflate the system’s layout and visual aspects with the raw data and functional aspects. This is a problem because it can be difficult to explain to a computer how to interact with a graphical interface. And this problem gets worse because changing the visual aspects of a program may also require us to reteach the computer how to interact with this new graphical interface. In effect, while changes may simply be cosmetic to us, they’re completely unrecognizable to a computer. Put differently, to a computer there is no such thing as “cosmetic only.”
API 是专门为具有重要属性的计算机提供的接口,以方便计算机使用它们。例如,这些界面没有视觉效果,因此无需担心表面上的变化。而且这些界面通常只以“兼容”的方式发展(见第 24 章),因此面对新的变化时无需重新教计算机任何东西。简而言之,API 提供了一种方式来表达计算机以安全稳定的方式进行交互所需的语言。
APIs are interfaces specifically for computers with important properties to make it easy for computers to use them. For example, these interfaces have no visual aspects, so there’s no need to worry about superficial changes. And these interfaces generally evolve in only “compatible” ways (see chapter 24), so there’s no need to reteach the computer anything in the face of new changes. In short, APIs provide a way to speak the language computers need to interact in a safe and stable way.
但这并不止于简单的自动化。API 还打开了组合的大门,这使我们能够像乐高积木一样对待功能,以新颖的方式将各个部分组装在一起,以构建比它们的部分总和更大的东西。为了完成这个循环,这些新的 API 组合同样可以加入可重用构建块的行列,从而实现更复杂和非凡的未来项目。
But this doesn’t stop at simple automation. APIs also open the door to composition, which allows us to treat functionality like Lego building blocks, assembling pieces together in novel ways to build things that are much larger than the sum of their parts. To complete the cycle, these new compositions of APIs can likewise join the ranks of reusable building blocks, enabling even more complex and extraordinary future projects.
但这引出了一个重要的问题:我们如何确保我们构建的 API 像乐高积木一样组装在一起?让我们首先看一个策略,称为面向资源 。
But this leads to an important question: how can we make sure the APIs we build fit together like Lego bricks? Let’s start by looking at one strategy for this, called resource orientation.
许多现在存在的 Web API 有点像仆人:你要求他们做某事,他们就会去做。例如,如果我们想要我们家乡的天气,我们可能会命令 Web APIpredictWeather(postalCode=10011)喜欢仆人。这种通过调用预先配置的子例程或方法来命令另一台计算机的方式通常被称为进行“远程过程调用”” (RPC),因为我们有效地调用了一个库函数(或过程),以便在可能距离很远(或远程)的另一台计算机上执行。像这样的 API 的关键方面是主要关注正在执行的操作。也就是说,我们考虑计算天气 ( predictWeather(postalCode=...)) 或加密数据 ( encrypt(data=...)) 或发送电子邮件 ( sendEmail(to=...)),每个都强调“做”某事。
Many web APIs that exist today act a bit like servants: you ask them to do something and they go off and do it. For example, if we want the weather for our hometown we might order the web API to predictWeather(postalCode=10011)like a servant. This style of ordering another computer around by calling a preconfigured subroutine or method is often referred to as making a “remote procedure call” (RPC) because we’re effectively calling a library function (or procedure) to be executed on another computer that is somewhere potentially far away (or remote). The critical aspect of APIs like this is the primary focus on the actions being performed. That is, we think about calculating the weather (predictWeather(postalCode=...)) or encrypting data (encrypt(data=...)) or sending an email (sendEmail(to=...)), each with an emphasis on “doing” something.
那么为什么不是所有的 API 都是面向 RPC 的呢?其中一个主要原因与“有状态”的概念有关,其中 API 调用可以是“有状态的”或“无状态的”。当一个 API 调用可以独立于所有其他 API 请求而没有任何额外的上下文或数据时,它被认为是无状态的。例如,预测天气的 Web API 调用仅涉及一个独立输入(邮政编码),因此将被视为无状态。另一方面,存储用户最喜欢的城市并提供这些城市的天气预报的 Web API 没有运行时输入,但要求用户已经存储了他们感兴趣的城市。因此,这种 API 请求,涉及其他先前的请求或先前存储的数据,将被视为有状态的。事实证明,RPC 风格的 API 非常适合无状态功能,但当我们引入有状态 API 方法时,它们往往不太适合。
So why aren’t all APIs RPC-oriented? One of the main reasons has to do with the idea of “statefulness,” where API calls can either be “stateful” or “stateless.” An API call is considered stateless when it can be made independently from all other API requests, with no additional context or data whatsoever. For example, a web API call to predict the weather involves only one independent input (the postal code) and would therefore be considered stateless. On the other hand, a web API that stores a user’s favorite cities and provides weather forecasts for those cities has no runtime inputs but requires a user to already have stored the cities they’re interested in. As a result, this kind of API request, involving other prior requests or previously stored data, would be considered stateful. It turns out that RPC-style APIs are great for stateless functionality, but they tend to be a much poorer fit when we introduce stateful API methods.
注意如果您碰巧熟悉 REST,那么现在可能是指出本节具体不是关于 REST 和 RESTful API 的好时机,而是更普遍地关于强调“资源”的 API(就像大多数 RESTful API 所做的那样)。换句话说,虽然与 REST 的主题有很多重叠,但本节比 REST 更笼统。
NOTE If you happen to be familiar with REST, now might be a good time to point out that this section is not about REST and RESTful APIs specifically, but more generally about APIs that emphasize “resources” (as most RESTful APIs do). In other words, while there will be a lot of overlap with the topic of REST, this section is a bit more general than just REST.
为了解这是为什么,让我们考虑一个用于预订航班的有状态 API 示例。在表 1.1 中,我们可以看到用于与航空公司旅行计划交互的 RPC 列表,涵盖诸如安排新预订、查看现有预订和取消不需要的旅行等操作。
To see why this is, let’s consider an example of a stateful API for booking airline flights. In table 1.1, we can see a list of RPCs for interacting with airline travel plans, covering actions such as scheduling new bookings, viewing existing bookings, and canceling unwanted travel.
Table 1.1 Summary of methods for an example flight-booking API
这些 RPC 中的每一个都具有很好的描述性,但不可避免地需要我们记住这些 API 方法,每个方法都与其他方法略有不同。例如,有时一个方法会谈论“航班”(例如,RescheduleFlight()),而其他时候则对“预订”(例如,CancelReservation())进行操作。我们还必须记住使用了动作的许多同义形式中的哪些。例如,我们需要记住查看所有预订的方式是ShowFlights()、ShowAllFlights()、ListFlights()还是ListAllFlights()(在本例中为ShowAllFlights())。但是我们能做些什么来解决这个问题呢?答案以标准化的形式出现。
Each of these RPCs is pretty descriptive, but there’s no escaping the requirement that we memorize these API methods, each of which is subtly different from the others. For example, sometimes a method talks about a “flight” (e.g., RescheduleFlight()) and other times operates on a “reservation” (e.g., CancelReservation()). We also have to remember which of the many synonymous forms of the action were used. For example, we need to remember whether the way to see all of our bookings is ShowFlights(), ShowAllFlights(), ListFlights(), or ListAllFlights() (in this case, it’s ShowAllFlights()). But what can we do to solve this? The answer comes in the form of standardization.
资源导向旨在通过提供一组标准的构建块来帮助解决这个问题,以便在两个领域设计 API 时使用。首先,面向资源的 API 依赖于“资源”的概念,这是我们存储和交互的关键概念,标准化了 API 管理的“事物”。其次,面向资源的 API 不是为我们能想到的任何操作使用任意 RPC 名称,而是将操作限制为一个小的标准集(如表 1.2 中所述),这些标准集适用于每个资源以在 API 中形成有用的操作。稍微不同地考虑一下,面向资源的 API 实际上只是一种特殊类型的 RPC 风格的 API,其中每个 RPC 都遵循一个清晰和标准化的模式:<StandardMethod><Resource>().
Resource orientation aims to help with this problem by providing a standard set of building blocks to use when designing an API in two areas. First, resource-oriented APIs rely on the idea of “resources,” which are the key concepts we store and interact with, standardizing the “things” that the API manages. Second, rather than using arbitrary RPC names for any action we can think of, resource-oriented APIs limit actions to a small standard set (described in table 1.2), which apply to each of the resources to form useful actions in the API. Thinking of this a bit differently, resource-oriented APIs are really just a special type of RPC-style APIs where each RPC follows a clear and standardized pattern: <StandardMethod><Resource>().
Table 1.2 Summary of standard methods and their meanings
如果我们沿着这条特殊的、有限的 RPC 的路线走下去,这意味着我们可以提出一个单一的资源(例如,FlightReservation)并通过一组标准方法获得等效的功能,而不是表 1.1 中显示的各种不同的 RPC 方法,如表1.3所示。
If we go down this route of special, limited RPCs, this means that instead of the variety of different RPC methods shown in table 1.1, we could come up with a single resource (e.g., FlightReservation) and get equivalent functionality with the set of standard methods, shown in table 1.3.
Table 1.3 Summary of standard methods applied to the flight resource
标准化显然更有条理,但这是否意味着所有面向资源的 API 都严格优于面向 RPC 的 API?实际上,不。对于某些场景,面向 RPC 的 API 将更适合(特别是在 API 方法是无状态的情况下)。然而,在许多其他情况下,面向资源的 API 将更容易让用户学习、理解和记忆。这是因为面向资源的 API 提供的标准化可以很容易地将您已经知道的(例如,标准方法集)与您可以轻松学习的(例如,新资源的名称)结合起来,开始与API 马上。更具体地说,如果你熟悉,比如说,五种标准方法,那么,多亏了可靠模式的力量,
Standardization is clearly more organized, but does that mean that all resource-oriented APIs are strictly better than RPC-oriented APIs? Actually, no. For some scenarios RPC-oriented APIs will be a better fit (particularly in the case where the API method is stateless). In many other cases however, resource-oriented APIs will be much easier for users to learn, understand, and remember. This is because the standardization provided by resource-oriented APIs makes it easy to combine what you already know (e.g., the set of standard methods) with what you can easily learn (e.g., the name of a new resource) to start interacting with the API right away. Put a bit more numerically, if you are familiar with, say, five standard methods, then, thanks to the power of a reliable pattern, learning about one new resource is actually the same as learning five new RPCs.
显然,重要的是要注意并非每个 API 都是相同的,并且根据“要学习的东西”的待办事项列表的大小来定义 API 的复杂性有点粗略。另一方面,这里有一个重要的原则:模式的力量。似乎学习可组合的部分并将它们组合成遵循既定模式的更复杂的事物往往比学习每次都遵循自定义设计的预构建复杂事物更容易。由于面向资源的 API 利用了久经考验的设计模式的力量,它们通常更容易学习,因此比面向 RPC 的等效 API 更“好”。但这给我们带来了一个重要的问题:“更好”在这里意味着什么?我们怎么知道 API 是否“好”?“好”到底是什么意思意思?
Obviously, it’s important to note that not every API is the same, and it’s a bit crude to define the complexity of an API in terms of the size of a to-do list of “stuff to learn.” On the other hand, there is an important principle at work here: the power of patterns. It seems that learning about composable pieces and combining them into more complex things that follow a set pattern tends to be easier than learning about pre-built complex things that follow a custom design every time. Since resource-oriented APIs exploit the power of battle-tested design patterns, they are often easier to learn and therefore “better” than their RPC-oriented equivalents. But this brings us to an important question: What does “better” mean here? How do we know if an API is “good”? What does “good” even mean?
前我们探索了使 API 变得“好”的几个不同方面,我们首先需要深入研究为什么我们拥有 API。换句话说,首先构建 API 的目的是什么?通常这可以归结为两个简单的原因:
Before we explore a few of the different aspects that tend to make APIs “good,” we first need to dig into why we have an API at all. In other words, what is the purpose of building an API in the first place? Often this comes down to two simple reasons:
例如,我们可能有一个在将文本从一种语言翻译成另一种语言方面表现出色的系统。世界上可能有很多人想要这种能力,但仅此还不够。毕竟,我们可以启动一个翻译移动应用程序来公开这个惊人的翻译系统而不是 API。为了获得一个 API,需要此功能的人必须也想编写一个使用它的程序。鉴于这两个标准,在考虑 API 的理想品质时,这会将我们引向何方?
For example, we might have a system that is amazing at translating text from one language to another. There are probably lots of people in the world who want this ability, but that alone isn’t enough. After all, we could launch a translation mobile app that exposes this amazing translation system instead of an API. To merit an API at all, the people who want this functionality must also want to write a program that uses it. Given these two criteria, where does this lead us when thinking about the desirable qualities of an API?
开始最重要的一点是,无论最终的界面是什么样子,整个系统都必须是可操作的。换句话说,它必须做用户真正想要的事情。如果这是一个打算将文本从一种语言翻译成另一种语言的系统,那么它实际上必须能够这样做。此外,大多数系统可能有许多非操作性需求。例如,如果我们的系统将文本从一种语言翻译成另一种语言,则可能存在与延迟(例如,翻译任务应该花费几毫秒,而不是几天)或准确性(例如,翻译不应该是误导)。正是这两个方面共同构成了一个系统。
Starting with the most important piece, no matter what the final interface looks like, the system as a whole must be operational. In other words, it must do the thing users actually want. If this is a system that intends to translate text from one language to another, it must actually be able to do so. Additionally, most systems are likely to have many nonoperational requirements. For example, if our system translates text from one language to another, there may be nonoperational requirements related to things like latency (e.g., the translation task should take a few milliseconds, not a few days) or accuracy (e.g., translations should not be misleading). It’s these two aspects together that we say constitute the operational aspects of a system.
如果系统能够做某事很重要,系统的界面允许用户清楚、简单地表达他们想做的事情也同样重要。换句话说,如果系统将文本从一种语言翻译成另一种语言,则 API 的设计应确保有一种清晰而简单的方法来完成此操作。在这种情况下,它可能是一个名为TranslateText(). 这类事情听起来很明显,但实际上可能比看起来更复杂。
If it’s important that a system is able to do something, it’s just as important that the interface to that system allows users to express the thing they want to do clearly and simply. In other words, if the system translates text from one language to another, the API should be designed such that there is a clear and simple way to do so. In this case, it might be an RPC called TranslateText(). This type of thing might sound obvious, but it can actually be more complicated than it seems.
这种隐藏的复杂情况的一个例子是 API 已经支持某些功能,但由于我们的疏忽,我们没有意识到用户需要它,因此没有为用户构建一种表达方式来访问该功能. 像这样的场景往往表现为变通办法,在这种情况下,用户会做一些不寻常的事情来访问隐藏的功能。例如,如果一个 API 提供了将文本从一种语言翻译成另一种语言的能力,那么即使用户对翻译任何东西都不感兴趣,也有可能强迫 API 充当语言检测器,如清单 1.1 所示. 正如您想象的那样,如果用户有一个名为DetectLanguage()而不是进行大量的 API 调用来猜测语言。
One example of this hidden complication is the case where an API supports some functionality already but, due to an oversight on our part, we didn’t realize that users wanted it and therefore didn’t build an expressive way for users to access that functionality. Scenarios like these tend to manifest as workarounds, where users do unusual things to access hidden functionality. For example, if an API provides the ability to translate text from one language to another, then it’s possible a user could coerce the API into acting as a language detector even if they’re not really interested in translating anything, as shown in listing 1.1. As you might imagine, it would be far better if users had an RPC called DetectLanguage() rather than making lots of API calls guessing at the language.
清单 1.1 仅使用 TranslateText API 方法检测语言的功能
Listing 1.1 Functionality to detect language using only a TranslateText API method
functiondetectLanguage(inputText: string):string{ const supportedLanguages: string[] = ['en', 'es', ... ]; for (let language of supportedLanguages) { let translatedText = TranslateApi.TranslateText({ ❶ text: inputText, targetLanguage: language }); if (translatedText == inputText) { ❷ return language; } } return null; ❸ }
❶这假设所讨论的 API 定义了一个 TranslateText 方法,该方法接受一些输入文本和要翻译成的目标语言。
❶ This assumes the API in question defines a TranslateText method that takes some input text and a target language to translate into.
❷如果翻译后的文本与输入的文本相同,我们就知道这两种语言是相同的。
❷ If the translated text is the same as the input text, we know that the two languages are the same.
❸如果没有找到与输入文本相同的翻译文本,则返回 null,表示无法检测输入文本的语言。
❸ If we don’t find translated text that is the same as the input text, we return null, indicating we can’t detect the language of the input text.
正如这个示例所示,支持某些功能但不能让用户轻松访问该功能的 API 并不是很好。另一方面,富有表现力的 API 为用户提供了明确指示他们想要什么(例如,翻译文本)甚至他们希望如何完成的能力(例如,“在 150 毫秒内,准确率为 95%”)。
As this example shows, APIs that support certain functionality but don’t make it easy for users to access that functionality would be not very good. On the other hand, APIs that are expressive provide the ability for users to clearly dictate exactly what they want (e.g., translate text) and even how they want it done (e.g., “within 150 milliseconds, with 95% accuracy”).
一与任何系统的可用性相关的最重要的事情之一就是简单性。虽然很容易争辩说简单就是减少 API 中事物(例如,RPC、资源等)的数量,但不幸的是,这种情况很少见。例如,一个 API 可以依赖于一个单一的ExecuteAction()方法处理所有功能;但是,这并没有真正简化任何事情。相反,它将复杂性从一处(大量不同的 RPC)转移到另一处(单个 RPC 中的大量配置)。那么一个简单的 API 究竟是什么样子的呢?
One of the most important things related to the usability of any system is simplicity. While it’s easy to argue that something being simple is reducing the number of things (e.g., RPCs, resources, etc.) in an API, unfortunately this is rarely the case. For example, an API could rely on a single ExecuteAction() method that handles all functionality; however, that’s not really simplifying anything. Instead, it shifts complexity from one place (lots of different RPCs) to another (lots of configuration in a single RPC). So what exactly does a simple API look like?
API 不应试图过度减少 RPC 的数量,而应旨在以尽可能最直接的方式公开用户想要的功能,使 API 尽可能简单,但不能更简单。例如,假设一个翻译 API 想要添加检测某些输入文本的语言的能力。我们可以通过在翻译响应中返回检测到的源文本来做到这一点;但是,这仍然通过将其隐藏在为其他目的而设计的方法中来混淆功能。相反,创建一种专门为此目的而设计的新方法会更有意义,例如DetectLanguage(). (请注意,我们在翻译内容时可能还会包括检测到的语言,但这完全是为了另一个目的。)
Rather than trying to excessively reduce the number of RPCs, APIs should aim to expose the functionality that users want in the most straightforward way possible, making the API as simple as possible, but no simpler. For example, imagine a translation API wanted to add the ability to detect the language of some input text. We could do this by returning the detected source text on the response of a translation; however, this still obfuscates the functionality by hiding it inside a method designed for another purpose. Instead, it would make more sense to create a new method that is designed specifically for this purpose, such as DetectLanguage(). (Note that we might also include the detected language when translating content, but that’s for another purpose entirely.)
另一个关于简单性的常见立场采用了关于“常见情况”的古老说法(“快速处理常见情况”),而是关注可用性,同时为边缘情况留出空间。这种重述是为了“让普通案例变得很棒,让高级案例成为可能”。这意味着无论何时您为了高级用户的利益而添加可能使 API 复杂化的内容,最好将这种复杂情况充分隐藏起来,不让只对常见情况感兴趣的典型用户看到。这使更频繁的场景变得简单和容易,同时仍然为需要它们的人启用更高级的功能。
Another common position on simplicity takes the old saying about the “common case” (“Make the common case fast”) but focuses instead on usability while leaving room for edge cases. This restatement is to “make the common case awesome and the advanced case possible.” This means that whenever you add something that might complicate an API for the benefit of an advanced user, it’s best to keep this complication sufficiently hidden from a typical user only interested in the common case. This keeps the more frequent scenarios simple and easy, while still enabling more advanced features for those who want them.
例如,假设我们的翻译 API 包含翻译文本时使用的机器学习模型的概念,我们不指定目标语言,而是根据目标语言选择一个模型,并将该模型用作“翻译引擎” ” 虽然此功能为用户提供了更多的灵活性和控制,但它也更加复杂,图 1.3 中显示了新的常见情况。
For example, let’s imagine that our translation API includes the concept of a machine learning model to be used when translating text, where instead of specifying the target language, we choose a model based on the target language and use that model as the “translating engine.” While this functionality provides much more flexibility and control to users, it is also much more complex, with the new common case shown in figure 1.3.
Figure 1.3 Translating text after choosing a model
如我们所见,我们有效地提高了翻译某些文本的难度,以换取对更高级功能的支持。为了更清楚地看到这一点,将清单 1.2 中显示的代码与调用的简单性进行比较TranslateText("Hello world", "es")。
As we can see, we’ve effectively made it much more difficult to translate some text in exchange for supporting the more advanced functionality. To see this more clearly, compare the code shown in listing 1.2 to the simplicity of calling TranslateText("Hello world", "es").
Listing 1.2 Translating text after choosing a model
functiontranslateText(inputText: string, targetLanguage: string):string{ let sourceLanguage = TranslateAPI.DetectLanguage(inputText); ❶ let model = TranslateApi.ListModels({ ❷ filter: `sourceLanguage:${sourceLanguage} targetLanguage:${targetLanguage}`, })[0]; return TranslateApi.TranslateText({ text: inputText, modelId: model.id ❸ }); }
❶既然要选择机型,首先要知道输入文本的语言。要确定这一点,我们可以依赖 API 提供的假设 DetectLanguage() 方法。
❶ Since we need to choose a model, we first need to know the language of the input text. To determine this we could rely on the hypothetical DetectLanguage() method provided by the API.
❷一旦我们知道源语言和目标语言,我们就可以选择 API 提供的任何匹配模型。
❷ Once we know both the source and destination languages, we can choose any of the matching models provided by the API.
❸现在我们终于拥有了所有必需的输入,我们可以回到将文本翻译成目标语言的最初目标。
❸ Now that we finally have all the required inputs, we can get back to our original goal of translating text into a target language.
我们如何才能将这个 API 设计得尽可能简单,但又不简单,同时让普通案例变得很棒,让高级案例成为可能?由于常见情况涉及并不真正关心特定模型的用户,我们可以通过设计 API 使其接受 atargetLanguage或modelId. 高级案例仍然有效(事实上,清单 1.2 中显示的代码将继续有效),但普通案例看起来简单得多,仅依赖于一个targetLanguage参数(并期待modelId参数未定义)。
How could we design this API to be as simple as possible, but no simpler as well as make the common case awesome and the advanced case possible? Since the common case involves users who don’t really care about a specific model, we could do this by designing the API so that it accepts either a targetLanguage or a modelId. The advanced case would still work (in fact, the code shown in listing 1.2 would continue to work), but the common case would look far simpler, relying on just a targetLanguage parameter (and expecting the modelId parameter to be left undefined).
Listing 1.3 Translating text to a target language (the common case)
functiontranslateText(inputText: string, targetLanguage: string, modelId?: string):string{ return TranslateApi.TranslateText({ text: inputText, targetLanguage: targetLanguage, modelId: modelId, }); }
现在我们已经了解了简单性是“好”API 的重要属性的一些背景知识,让我们看看最后一部分:可预测性。
Now that we have some background on how simplicity is an important attribute of a “good” API, let’s look at the final piece: predictability.
尽管我们生活中的惊喜有时会很有趣,一个不属于惊喜的地方是在 API 中,无论是在接口定义中还是在底层行为中。这有点像关于投资的古老格言:“如果它令人兴奋,那你就做错了。” 那么我们所说的“不足为奇”的 API 是什么意思呢?
While surprises in our lives can sometimes be fun, one place surprises do not belong is in APIs, either in the interface definition or underlying behavior. This is a bit like the old adage about investing: “If it’s exciting, you’re doing it wrong.” So what do we mean by “unsurprising” APIs?
不出所料的 API 依赖于应用于 API 表面定义和行为的重复模式。例如,如果翻译文本的 API 有一个TranslateText()方法它将输入内容作为参数作为一个名为text,那么当我们添加一个DetectLanguage()方法时,输入的内容也应该被调用text(不是inputTextorcontent或textContent)。虽然这现在看起来很明显,但请记住,许多 API 是由多个团队构建的,并且在提供一组选项时选择调用字段的内容通常是任意的。这意味着当两个不同的人负责这两个不同的领域时,他们当然有可能做出不同的任意选择。当发生这种情况时,我们最终会得到一个不一致(因此令人惊讶)的 API。
Unsurprising APIs rely on repeated patterns applied to both the API surface definition and the behavior. For example, if an API that translates text has a TranslateText() method that takes as a parameter the input content as a field called text, then when we add a DetectLanguage() method, the input content should be called text as well (not inputText or content or textContent). While this might seem obvious now, keep in mind that many APIs are built by multiple teams and the choice of what to call fields when presented a set of options is often arbitrary. This means that when two different people are responsible for these two different fields, it’s certainly possible that they’ll make different arbitrary choices. When this happens, we end up with an inconsistent (and therefore surprising) API.
尽管这种不一致看起来微不足道,但事实证明,诸如此类的问题比它们看起来要重要得多。这是因为实际上很少有 API 用户通过通读所有 API 文档来了解每一个细节。相反,用户阅读的内容足以完成他们想做的事情。这意味着,如果有人得知某个字段text在一个请求消息中被调用,他们几乎肯定会假设它在另一个请求消息中以相同的方式命名,有效地建立在他们已经学到的东西的基础上,对他们没有的东西做出有根据的猜测'还学会了。如果此过程失败(例如,因为另一条消息命名该字段inputText),他们的工作效率遇到瓶颈,他们不得不停下手头的工作去弄清楚为什么他们的假设失败了。
Even though this inconsistency might seem insignificant, it turns out that issues like these are much more important than they appear. This is because it’s actually pretty rare that users of an API learn each and every detail by thoroughly reading all the API documentation. Instead, users read just enough to accomplish what they want to do. This means that if someone learns that a field is called text in one request message, they’re almost certainly going to assume it’s named the same way in another, effectively building on what they’ve already learned to make an educated guess about things they haven’t yet learned. If this process fails (e.g., because another message named that field inputText), their productivity hits a brick wall and they have to stop what they’re doing to go figure out why their assumptions failed.
显而易见的结论是,依赖于重复的、可预测的模式(例如,始终如一地命名字段)的 API 学习起来更容易、更快,因此也更好。类似的好处来自更复杂的模式,例如我们在探索面向资源的 API 时看到的标准操作。这将我们带到了本书的全部目的:使用众所周知的、定义明确的、清晰的和(希望)简单的模式构建的 API 将导致 API 可预测且易于学习,这应该导致整体“更好”蜜蜂。现在我们已经很好地掌握了 API 以及它们的优点,让我们开始考虑我们可以遵循的更高级别的模式设计他们。
The obvious conclusion is that APIs that rely on repeated, predictable patterns (e.g., naming fields consistently) are easier and faster to learn and therefore better. And similar benefits arise from more complex patterns, such as the standard actions we saw in our exploration of resource-oriented APIs. This brings us to the entire purpose of this book: APIs built using well-known, well-defined, clear, and (hopefully) simple patterns will lead to APIs that are predictable and easy to learn, which should lead to overall “better” APIs. Now that we have a good grasp on APIs and what makes them good, let’s start thinking about higher-level patterns we can follow when designing them.
Interfaces are contracts that define how two systems should interact with one another.
APIs are special types of interfaces that define how two computer systems interact with one another, coming in many forms, such as downloadable libraries and web APIs.
Web APIs are special because they expose functionality over a network, hiding the specific implementation or computational requirements needed for that functionality.
面向资源的 API 是一种设计 API 的方法,它通过依赖一组标准的操作(称为方法)来降低复杂性,跨越一组有限的事物(称为资源)。
Resource-oriented APIs are a way of designing APIs to reduce complexity by relying on a standard set of actions, called methods, across a limited set of things, called resources.
What makes APIs “good” is a bit ambiguous, but generally good APIs are operational, expressive, simple, and predictable.
现在我们已经掌握了 API 是什么以及是什么让它们“好”,我们可以探索在构建 API 时如何应用不同的模式。我们将从探讨什么是 API 设计模式、它们为何重要以及如何在后面的章节中描述它们开始。最后,我们将查看一个示例 API 并了解使用预构建的 API 设计模式如何节省大量时间和未来的麻烦。
Now that we have a grasp of what APIs are and what makes them “good,” we can explore how we might apply different patterns when building an API. We’ll start by exploring what API design patterns are, why they matter, and how they’ll be described in later chapters. Finally, we’ll look at an example API and see how using pre-built API design patterns can save lots of time and future headaches.
前我们开始探索 API 设计模式 我们必须打下一些基础,从一个简单的问题开始:什么是设计模式?如果我们注意到软件设计指的是为解决问题而编写的一些代码的结构或布局,那么软件设计模式就是当一个特定的设计可以反复应用于许多类似的软件问题时发生的情况,只需要微调以适应不同的场景。这意味着该模式不是我们用来解决单个问题的一些预建库,而是更多的解决类似结构问题的蓝图。
Before we start exploring API design patterns we have to lay a bit of groundwork, starting with a simple question: what is a design pattern? If we note that software design refers to the structure or layout of some code written in order to solve a problem, then a software design pattern is what happens when a particular design can be applied over and over to lots of similar software problems, with only minor adjustments to suit different scenarios. This means that the pattern isn’t some pre-built library we use to solve an individual problem, but instead more of a blueprint for solving similarly structured problems.
如果这看起来太抽象,让我们把它固定下来,想象我们想在我们的后院放一个棚子。有几种不同的选择可供选择,从我们几百年前所做的,到我们今天所做的,这要归功于 Lowe's 和 Home Depot 等公司的魔力。选项有很多,但常见的有以下四种:
If this seems too abstract, let’s firm it up and imagine that we want to put a shed in our backyard. There are a few different options to choose from, ranging from what we did a few hundred years ago to what we do today thanks to the magic of companies like Lowe’s and Home Depot. There are lots of options, but four common ones are as follows:
Buy a shed kit (blueprints and materials) and assemble it ourselves.
Buy a set of shed blueprints, modify the design as necessary, then build it ourselves.
如果我们根据它们的软件等价物来考虑这些,它们的范围从使用预构建的现成软件包一直到编写完全定制的系统来解决我们的问题。在表 2.1 中,我们看到随着我们在列表中移动,这些选项变得越来越困难,但也增加了从一个选项到下一个选项的越来越多的灵活性。换句话说,难度最小的灵活性最小,难度最大的灵活性最大y。
If we think of these in terms of their software equivalent, they would range from using a pre-built off-the-shelf software package all the way through writing an entirely custom system to solve our problem. In table 2.1 we see that these options get more and more difficult as we move through the list, but also add more and more flexibility from one option to the next. In other words, the least difficult has the least flexibility, and the most difficult has the most flexibility.
Table 2.1 Comparison of ways to build a shed with ways to build software
大多数时候,软件工程师倾向于选择“从头开始构建”选项。有时这是必要的,特别是在我们要解决的问题是新问题的情况下。其他时候,这种选择在成本效益分析中胜出,因为我们的问题完全不同,足以阻止我们依赖更简单的选择之一。还有一些时候,我们知道有一个库正好(或足够接近)解决了我们的问题,我们选择依赖已经解决了手头问题的其他人。事实证明,选择中间选项之一(定制现有软件或从设计文档构建)不太常见,但可能会更频繁地使用并取得很好的效果。这就是设计模式适合的地方。
Software engineers tend to choose the “build from scratch” option most of the time. Sometimes this is necessary, particularly in cases where the problems we’re solving are new. Other times this choice wins out in a cost-benefit analysis because our problem is just different enough to prevent us from relying on one of the easier options. And still other times we know of a library that happens to solve our problem exactly (or close enough), and we choose to rely on someone else having already solved the problem at hand. It turns out that choosing one of the in-between options (customizing existing software or building from a design document) is much less common but could probably be used more often with great results. And this is where design patterns fit in.
在高层次上,设计模式是应用于软件的“从蓝图构建”选项。就像棚屋的蓝图带有尺寸、门窗位置以及屋顶材料一样,设计模式带有我们编写的代码的一些规范和细节。在软件中,这通常意味着指定代码的高级布局以及依靠布局解决特定设计问题的细微差别。然而,很少有设计模式可以完全独立使用。大多数情况下,设计模式关注特定组件而不是整个系统。换句话说,蓝图侧重于单个方面(如屋顶形状)或组件(如窗户设计)的形状,而不是整个棚屋。乍一看,这似乎是一个缺点,但只有当目标完全是建造一个棚屋时才会如此。如果您正在尝试建造类似棚屋但又不完全是棚屋的东西,那么为每个单独的组件制定蓝图意味着您可以将它们混合并匹配在一起以准确地建造您想要建造的东西,选择屋顶形状 A 和窗口设计 B。这会延续到我们对设计模式的讨论中,因为每个设计模式都倾向于关注系统的单个组件或问题类型,通过组装大量预先设计的部分来帮助您构建您想要的东西。
At a high level, design patterns are the “build from blueprints” option applied to software. Just like blueprints for a shed come with the dimensions, locations of doors and windows, and the materials for the roof, design patterns come with some set of specifications and details for the code that we write. In software this often means specifying a high-level layout of the code as well as the nuances of relying on the layout to solve a particular design problem. However, it’s rare that a design pattern is made to be used entirely on its own. Most often, design patterns focus on specific components rather than entire systems. In other words, the blueprints focus on the shape of a single aspect (like the roof shape) or component (like a window design) rather than the entire shed. This might seem like a downside at first glance, but that’s only the case if the goal is exactly to build a shed. If you’re trying to build something sort of like a shed, but not quite a shed, then having blueprints for each individual component means you can mix and match lots of them together into exactly what you want to build, choosing roof shape A and window design B. This carries over into our discussion of design patterns, since each one tends to focus on a single component or problem type of your system, helping you build exactly what you want by assembling lots of pre-designed pieces.
例如,如果您想向系统添加调试日志记录,您可能需要一种且只有一种方法来记录消息。有很多方法可以做到这一点(例如,使用一个共享的全局变量),但碰巧有一种设计模式旨在解决这个软件问题。这种模式在开创性著作设计模式(Gamma 等人,1994 年)中有所描述,称为单例模式,它确保只创建一个类的单个实例。这个“蓝图”需要一个带有私有构造函数和一个静态方法的类getInstance(),它总是返回该类的单个实例(当且仅当它尚不存在时,它才会处理创建该单个实例)。这个模式一点也不完整(毕竟,拥有一个什么都不做的单例类有什么好处?);但是,当您需要解决总是只有一个类实例的小问题时,它是一个定义明确且经过良好测试的模式。
For example, if you want to add debug logging to your system, you’ll likely want one and only one way to log messages. There are lots of ways you could do this (for example, using a single shared global variable), but there happens to be a design pattern aimed at solving this software problem. This pattern, described in the seminal work Design Patterns (Gamma et al., 1994), is called the singleton pattern, and it ensures that only a single instance of a class is created. This “blueprint” calls for a class with a private constructor and a single static method called getInstance(), which always returns a single instance of the class (it handles creating that single instance if and only if it doesn’t exist yet). This pattern is not at all complete (after all, what good is it to have a singleton class that does nothing?); however, it’s a well-defined and well-tested pattern to follow when you need to solve this small compartmentalized problem of always having a single instance of a class.
既然我们知道了一般的软件设计模式是什么,我们就要问一个问题:什么是 API 设计模式?使用第 1 章中描述的 API 定义,API 设计模式只是一种应用于 API 而不是一般所有软件的软件设计模式。这意味着 API 设计模式,就像常规设计模式一样,只是设计和构建 API 方式的蓝图。由于重点是接口而不是实现,在大多数情况下,API 设计模式将只关注接口,而不必构建实现。虽然大多数 API 设计模式通常对这些接口的底层实现保持沉默,但有时它们会规定API的某些方面的行为。例如,API 设计模式可能指定某个 RPC 可以是最终一致的,这意味着从该 RPC 返回的数据可能略微陈旧(例如,它可以从缓存而不是权威存储系统中读取)。
Now that we know what software design patterns are generally, we have to ask the question: what are API design patterns? Using the definition of an API as described in chapter 1, an API design pattern is simply a software design pattern applied to an API rather than all software generally. This means that API design patterns, just like regular design patterns, are simply blueprints for ways of designing and structuring APIs. Since the focus is on the interface rather than the implementation, in most cases an API design pattern will focus on the interface exclusively, without necessarily building out the implementation. While most API design patterns will often remain silent on the underlying implementation of those interfaces, sometimes they dictate certain aspects of the API ’s behavior. For example, an API design pattern might specify that a certain RPC can be eventually consistent, meaning that the data returned from that RPC could be slightly stale (for example, it could be read from a cache rather than the authoritative storage system).
我们将在后面的部分更正式地解释我们计划如何记录 API 模式,但首先让我们快速了解一下为什么我们应该关心 API 设计模式全部。
We’ll get into a more formal explanation of how we plan to document API patterns in a later section, but first let’s take a quick look at why we should care about API design patterns at all.
作为我们已经了解到,API 设计模式在构建 API 时很有用,就像建造棚子时的蓝图一样:它们充当我们可以在项目中使用的预先设计的构建块。我们没有深入研究的是为什么我们首先需要这些预先设计的蓝图。难道我们都不够聪明,无法构建好的 API 吗?我们不是最了解我们的业务和技术问题吗?虽然这种情况经常发生,但事实证明,我们用来构建设计精良的软件的一些技术在构建 API 时并不适用。更具体地说,敏捷开发过程特别提倡的迭代方法在设计 API 时很难应用。要了解原因,我们必须查看软件系统的两个方面。首先,我们通常要探索各种接口的灵活性(或刚性),然后我们必须了解界面的受众对我们进行更改和迭代整体设计的能力有何影响。让我们从灵活性开始。
As we already learned, API design patterns are useful in building APIs, just like blueprints are when building a shed: they act as pre-designed building blocks we can use in our projects. What we didn’t dig into is why we need these pre-designed blueprints in the first place. Aren’t we all smart enough to build good APIs? Don’t we know our business and technical problems best? While this is often the case, it turns out that some of the techniques we use to build really well-designed software don’t work as well when building APIs. More specifically, the iterative approach, advocated in particular by the agile development process, is difficult to apply when designing APIs. To see why, we have to look at two aspects of software systems. First, we have to explore the flexibility (or rigidity) of the various interfaces generally, and then we must understand what effect the audience of the interface has on our ability to make changes and iterate on the overall design. Let’s start by looking at flexibility.
正如我们在第 1 章中看到的,API 是一种特殊的接口,主要用于计算系统之间的交互。虽然以编程方式访问系统非常有价值,但它也更加脆弱,因为对界面的更改很容易导致使用该界面的人出现故障。例如,更改 API 中的字段名称会导致用户在更改名称之前编写的任何代码失败。从 API 服务器的角度来看,旧代码正在使用不再存在的名称请求一些东西。这与其他类型的界面(例如图形用户界面)截然不同(GUI),主要由人类而不是计算机使用,因此更能适应变化。这意味着即使更改可能令人沮丧或不美观,它通常不会导致我们根本无法再使用该界面的灾难性故障。例如,更改网页上按钮的颜色或位置可能很丑陋且不方便,但我们仍然可以弄清楚如何完成我们需要对界面执行的操作。
As we saw in chapter 1, APIs are special kinds of interfaces that are made primarily so computing systems can interact with one another. While having programmatic access to a system is very valuable, it’s also much more fragile and brittle in that changes to the interface can easily cause failures for those using the interface. For example, changing the name of a field in an API would cause a failure for any code written by users before the name change occurred. From the perspective of the API server, the old code is asking for something using a name that no longer exists. This is a very different scenario from other kinds of interfaces, such as graphical user-interfaces (GUIs), which are used primarily by humans rather than computers and as a result are much more resilient to change. This means that even though a change might be frustrating or aesthetically displeasing, it typically won’t cause a catastrophic failure where we can no longer use the interface at all. For example, changing the color or location of a button on a web page might be ugly and inconvenient, but we can still figure out how to accomplish what we need to do with the interface.
我们经常将接口的这一方面称为灵活性,说用户可以轻松适应更改的界面是灵活的,而即使是很小的更改(如重命名字段)也会导致完全失败的界面是刚性的。这种区别很重要,因为进行大量更改的能力在很大程度上取决于界面的灵活性。最重要的是,我们可以看到,僵化的界面让我们更难像在其他软件项目中那样迭代出出色的设计。这意味着我们通常最终会陷入所有设计决策中,无论好坏。这可能会让您认为 API 的刚性意味着我们永远无法使用迭代开发过程,但由于接口的另一个重要方面:可见性,情况并非总是如此。
We often refer to this aspect of an interface as its flexibility, saying that interfaces where users can easily accommodate changes are flexible and those where even small changes (like renaming fields) cause complete failures are rigid. This distinction is important because the ability to make lots and lots of changes is determined in large part by the flexibility of the interface. Most importantly we can see that rigid interfaces make it much more difficult for us to iterate toward a great design like we would in other software projects. This means that we often end up stuck with all design decisions, both good and bad. This might lead you to think that the rigidity of APIs implies we’ll never be able to use an iterative development process, but this is not always the case thanks to another important aspect of interfaces: visibility.
通常,我们可以将大多数界面分为两个不同的类别:您的用户可以看到并与之交互的界面(在通常称为前端的软件中) 和那些他们不能的(通常称为后端). 例如,当我们打开浏览器时,我们可以很容易地看到 Facebook 的图形用户界面;然而,我们无法看到 Facebook 如何存储我们的社交图谱和其他数据。要对可见性的这一方面使用更正式的术语,我们可以说前端(所有用户都可以看到并与之交互的部分)通常被认为是公开的后端(仅对较小的内部组可见)被认为是私有的. 这种区别很重要,因为它部分决定了我们对不同类型的接口进行更改的能力,尤其是像 API 这样的刚性接口。
Generally, we can put most interfaces into two different categories: those that your users can see and interact with (in software usually called the frontend) and those that they can’t (usually called the backend). For example, we can easily see the graphical user interface for Facebook when we open a browser; however, we don’t have the ability to see how Facebook stores our social graph and other data. To use more formal terms for this aspect of visibility, we can say that the frontend (the part that all users see and interact with) is usually considered public and the backend (only visible to a smaller internal group) is considered private. This distinction is important because it partly determines our ability to make changes to different kinds of interfaces, particularly rigid ones like APIs.
如果我们对公共界面进行更改,全世界都会看到并可能受到影响。由于受众如此之多,粗心地进行更改可能会导致用户生气或沮丧。虽然这当然适用于像 API 这样的刚性接口,但它也同样适用于灵活的接口。例如,在 Facebook 的早期,大多数主要的功能或设计更改都会在大学生中引起数周的愤怒。但是,如果接口不公开怎么办?更改仅供某些私有内部人员组的成员看到的后端接口是否有什么大不了的?在这种情况下,受更改影响的用户数量要少得多,甚至可能仅限于同一团队或同一办公室的人员,因此我们似乎重新获得了更多的更改自由。
If we make a change to a public interface, the whole world will see it and may be affected by it. Since the audience is so large, carelessly making changes could result in angry or frustrated users. While this certainly applies to rigid interfaces like APIs, it also applies to flexible interfaces just the same. For example, in the early days of Facebook most major functional or design changes caused outrage among college students for a few weeks. But what if the interface isn’t public? Is it a big deal to make changes to backend interfaces that are only seen by members of some private internal group of people? In this scenario the number of users affected by a change is much smaller, possibly even limited to people on the same team or in the same office, so it seems we have gained back a bit more freedom to make changes. This is great news because it means we should be able to iterate quickly toward an ideal design, applying agile principles along the way.
那么,为什么 API 很特别?事实证明,当我们设计许多 API(根据定义是严格的)并与世界共享时,我们确实在两个方面都有最坏的情况。这意味着进行更改比这两个属性的任何其他组合要困难得多(如表 2.2 中总结的那样)).
So why are APIs special? It turns out that when we design many APIs (which are rigid by definition) and share them with the world, we really have a worst-case scenario for both aspects. This means that making changes is much more difficult than any other combination of these two properties (as summarized in table 2.2).
Table 2.2 Difficulty of changing various interfaces
简而言之,这种“两全其美”的场景(既僵化又难以更改)使得可重用和经过验证的设计模式对于构建 API 比其他类型的软件更为重要。虽然代码在大多数软件项目中通常是私有的并且不可见,但 API 中的设计决策是最重要的,向服务的所有用户显示。由于这严重限制了我们对设计进行渐进式改进的能力,因此依赖经受住时间考验的现有模式对于一次就把事情做对而不是像大多数软件那样最终做对是非常有价值的。
Put simply, this “worst of both worlds” scenario (both rigid and difficult to change) makes reusable and proven design patterns even more important for building APIs than other types of software. While code is often private and out of sight in most software projects, design decisions in an API are front and center, shown to all of the users of the service. Since this seriously limits our ability to make incremental improvements on our designs, relying on existing patterns that have survived the tests of time are very valuable in getting things right the first time rather than just eventually as in most software.
现在我们已经探索了这些设计模式重要的一些原因,让我们通过剖析它并探索它的各种不同来进入 API 设计模式组件。
Now that we’ve explored some of the reasons these design patterns are important, let’s get into an API design pattern by dissecting it and exploring its various components.
像在软件设计的大多数部分,API 设计模式由几个不同的组件组成,每个组件负责使用模式本身的不同方面。显然,主要组件侧重于模式本身的工作方式,但还有其他组件针对使用设计模式的技术性较低的方面。这些是诸如找出一组给定问题存在的模式,了解该模式是否适合您正在处理的问题,以及了解为什么该模式以一种方式而不是使用(可能更简单)替代。
Like most pieces in software design, API design patterns are made up of several different components, each one responsible for a different aspect of consuming the pattern itself. Obviously the primary component focuses on how the pattern itself works, but there are other components targeted at the less technical aspects of consuming a design pattern. These are things like figuring out that a pattern exists for a given set of problems, understanding whether the pattern is a good fit for the problem you’re dealing with, and understanding why the pattern does things in one way rather than using a (possibly simpler) alternative.
由于这个解剖课可能会有点复杂,让我们假设我们正在构建一个存储数据的服务,并且该服务的客户需要一个 API,以便他们可以从服务中获取数据。我们将依靠这个示例场景来指导我们讨论接下来将探讨的每个模式组件,从头开始:名称。
Since this anatomy lesson could get a bit complicated, let’s imagine that we’re building a service that stores data and that the customers of that service want an API where they can get their data out of the service. We’ll rely on this example scenario to guide our discussion through each of the pattern components that we’ll explore next, starting with the beginning: the name.
每个目录中的设计模式有一个名称,用于唯一标识目录中的模式。该名称将具有足够的描述性以传达该模式正在做什么,但又不会冗长以至于在嘈杂的房间里大声喊叫并不容易。例如,在描述解决我们的导出数据示例场景的模式时,我们可以将其称为“导入、导出、备份、还原、快照和回滚模式”,但它可能更适合命名为“输入/输出模式”或简称“IO 模式”。
Each design pattern in the catalog has a name, given to uniquely identify the pattern in the catalog. The name will be descriptive enough to convey what the pattern is doing, but not so long-winded that it’s not easy to shout across a noisy room. For example, when describing a pattern that solves our example scenario of exporting data, we could call it “Import, export, back-up, restore, snapshot, and rollback pattern,” but it’s probably better named as “Input/Output pattern” or “IO pattern” for short.
虽然名称本身通常足以理解和识别模式,但有时它还不够冗长,无法充分解释模式所解决的问题。为确保对模式本身有一个简短的介绍,名称后面还会有一个简短的模式摘要,其中将简要描述它旨在解决的问题。例如,我们可以说输入/输出模式“提供了一种将数据移入或移出各种不同存储源和目的地的结构化方式”。简而言之,本节的总体目标是轻松快速地确定任何特定模式是否值得进一步研究作为解决给定问题的潜在契合点问题。
While the name itself is usually enough to understand and identify the pattern, it’s sometimes not quite verbose enough to sufficiently explain the problem that the pattern addresses. To ensure there is a short and simple introduction to the pattern itself, there will also be a short summary of the pattern following the name, which will have a brief description of the problem it is aiming to solve. For example, we might say that the input/output pattern “offers a structured way of moving data to or from a variety of different storage sources and destinations.” In short, the overall goal of this section is to make it easy to quickly identify whether any particular pattern is worth further investigation as a potential fit for solving a given problem.
自从API 设计模式的目标是为一类问题提供解决方案,一个好的起点是定义该模式旨在涵盖的问题空间。本节旨在解释基本问题,以便容易理解为什么我们首先需要一个模式。这意味着我们首先需要一个详细的问题陈述,它通常以以用户为中心的目标形式出现。在我们的数据导出示例中,我们可能会遇到用户“想要将一些数据从服务导出到另一个外部存储系统”的场景。
Since the goal of an API design pattern is to provide a solution for a category of problems, a good place to start is a definition of the problem space the pattern aims to cover. This section aims to explain the fundamental problem so that it’s easy to understand why we need a pattern for it in the first place. This means we first need a detailed problem statement, which often comes in the form of a user-focused objective. In the case of our data export example, we might have a scenario where a user “wants to export some data from the service into another external storage system.”
之后,我们必须更深入地挖掘用户想要完成的细节。例如,我们可能会发现用户需要将他们的数据导出到各种存储系统,而不仅仅是亚马逊的 S3。他们还可能需要对数据的导出方式施加进一步的限制,例如数据在传输前是否经过压缩或加密。这些要求将对设计模式本身产生直接影响,因此我们清楚地阐明我们正在使用该特定模式解决的问题的这些细节非常重要。
After that, we must dig a bit deeper into the details of what users want to accomplish. For example, we might find that users need to export their data to a variety of storage systems, not just Amazon’s S3. They also may need to apply further constraints on how the data is exported, such as whether it’s compressed or encrypted before transmission. These requirements will have a direct impact on the design pattern itself, so it’s important that we articulate these details of the problem we’re addressing with this particular pattern.
接下来,一旦我们更全面地了解了用户目标,我们就需要探索在实际实施的正常过程中可能出现的边缘情况。例如,我们应该了解当数据太大时系统应该如何表现(以及多大才算太大,因为这些词对不同的人来说通常意味着不同的数字)。我们还必须探索系统在故障情况下应该如何反应。例如,当导出作业失败时,我们应该说明是否应该重试。这些不寻常的场景可能比我们通常预期的要普遍得多,即使我们可能不必立即决定如何处理每个场景,但模式注意到这些空白以便最终可以填补它们是至关重要的通过一个执行。
Next, once we understand the user objectives more fully, we need to explore the edge cases that are likely to arise in the normal course of actual implementation. For example, we should understand how the system should behave when the data is too large (and how large is too large, since those words often mean different numbers to different people). We also must explore how the system should react in failure scenarios. For example, when an export job fails we should describe whether it should be retried. These unusual scenarios are likely to be much more common than we typically expect, and even though we might not have to decide how to address each scenario right away, it’s critical that the pattern take note of these blanks so that they can eventually be filled in by an implementation.
现在我们越来越接近有趣的部分:解释设计模式推荐的解决问题空间的方法。此时,我们不再专注于定义问题,而是专注于提供解决方案的高级描述。这意味着我们开始探索我们将采用的解决问题的策略以及我们将使用的方法。例如,在我们的导出数据场景中,本节将概述各种组件及其职责,例如一个组件用于描述要导出的数据的详细信息,另一个组件用于描述充当导出数据目的地的存储系统,还有一个用于描述在将数据发送到该目的地之前应用的加密和压缩设置。
Now we’re getting closer to the fun part: explaining what the design pattern recommends as a solution to the problem space. At this point, we’re no longer focused on defining the problem, but on offering a high-level description of the solution. This means that we get to start exploring the tactics we’ll employ to address the problem and the methods we’ll use to do so. For example, in our exporting data scenario, this section would outline the various components and their responsibilities, such as a component for describing the details of what data to export, another for describing the storage system that acts as a destination for the exported data, and still another for describing encryption and compression settings applied before sending the data to that destination.
在许多情况下,问题定义和解决方案要求列表将决定解决方案的大纲。在这些情况下,概述的目标是明确阐明这个大纲,而不是让它从问题描述中推断出来,不管解决方案看起来多么明显。例如,如果我们正在定义一种模式来搜索资源列表,那么使用查询参数似乎是显而易见的;然而,其他方面(例如该参数的格式或搜索的一致性保证)可能不那么明显,值得进一步讨论。毕竟,即使是显而易见的解决方案也可能具有值得解决的微妙含义,而且正如他们所说,细节往往决定成败。
In many cases the problem definition and list of solution requirements will dictate a general outline of a solution. In those cases, the goal of the overview is to explicitly articulate this outline rather than leaving it to be inferred from the problem description, regardless of how obvious a solution may seem. For example, if we’re defining a pattern for searching through a list of resources, it seems pretty obvious to have a query parameter; however, other aspects (such as the format of that parameter or the consistency guarantees of the search) might not be so obvious and merit further discussion. After all, even obvious solutions may have subtle implications that are worth addressing, and, as they say, the devil is often in the details.
其他时候,虽然问题定义明确,但可能没有一个明显的解决方案,而是多个不同的选项,每个选项都有自己的权衡取舍。例如,有许多不同的方法可以在 API 中对多对多关系进行建模,每种方法都有其不同的优点和缺点;然而,重要的是 API 选择一个选项并一致地应用它。在这种情况下,概述将讨论每个不同的选项和推荐模式采用的策略。本节可能包含对提到的其他可能选项的优点和缺点的简短讨论,但大部分讨论将留给模式末尾的权衡部分描述。
Other times, while the problem is well-defined, there may not be a single obvious solution, but instead several different options that may each have their own trade-offs. For example, there are many different ways to model many-to-many relationships in an API, each with its different benefits and drawbacks; however, it’s important that an API choose one option and apply it consistently. In cases like this, the overview will discuss each of the different options and the strategy employed by the recommended pattern. This section might contain a brief discussion of the benefits and drawbacks of the other possible options mentioned, but the bulk of that discussion will be left for the trade-offs section at the end of the pattern description.
我们已经谈到每个设计模式中最重要的部分:我们如何着手实施它。在这一点上,我们应该彻底了解我们试图解决的问题空间,并对我们将用来解决它的高级策略和策略有一个想法。本节最重要的部分是定义为代码的接口定义,它解释了使用此模式解决问题的 API 会是什么样子。API 定义将侧重于资源的结构以及与这些资源交互的各种特定方式。这将包括各种各样的事情,例如资源或请求中出现的字段、可以进入这些字段的数据格式(例如,Base64 编码的字符串),以及资源如何相互关联(例如,分层关系)。
We’ve gotten to the most important piece of every design pattern: how we go about implementing it. At this point, we should thoroughly understand the problem space that we’re trying to address and have an idea of the high-level tactics and strategy we’ll be employing to solve it. The most important piece of this section will be interface definitions defined as code, which explain what an API using this pattern to solve a problem would look like. The API definitions will focus on the structure of resources and the various specific ways to interact with those resources. This will include a variety of things such as the fields present on resources or requests, the format of the data that could go into those fields (e.g., Base64 encoded strings), as well as how the resources relate to one another (e.g., hierarchical relationships).
在许多情况下,API 表面和字段定义本身可能不足以解释 API 的实际工作方式。换句话说,虽然字段的结构和列表可能看起来很清楚,但这些结构的行为和不同字段之间的交互可能要复杂得多,而不是简单明了。在这些情况下,我们需要对设计的这些非显而易见的方面进行更详细的讨论。例如,当导出数据时,我们可能会指定一种方式,在传输到存储服务的途中使用字符串字段来指定压缩算法。在这种情况下,该模式可能会讨论该字段的各种可能值(它可能使用 Accept-Encoding HTTP 标头使用的相同格式),提供无效选项时该怎么做(它可能返回错误),gzip压缩).
In many cases, the API surface and field definitions themselves may not be sufficient to explain how the API actually works. In other words, while the structure and list of fields may seem clear, the behavior of those structures and interaction between different fields may be much more complex rather than simple and obvious. In those cases, we’ll need a more detailed discussion of these non-obvious aspects of the design. For example, when exporting data we may specify a way to compress it on the way to the storage service using a string field to specify the compression algorithm. In this situation, the pattern might discuss the various possible values of this field (it might use the same format used by the Accept-Encoding HTTP header), what to do when an invalid option is supplied (it might return an error), and what it means when a request leaves the field blank (it might default to gzip compression).
最后,本节将包括一个示例 API 定义,并附有注释,解释正确实现此模式的 API 应该是什么样子。这将在代码中定义,注释解释各个字段的行为,并将依赖于说明该模式解决的问题的场景的特定示例。这部分几乎肯定是最长的,并且有最多的细节。
Finally, this section will include an example API definition, with comments explaining what an API that correctly implements this pattern should look like. This will be defined in code, with comments explaining the behaviors of the various fields, and will rely on a specific example of a scenario illustrating the problem that’s addressed by the pattern. This section will almost certainly be the longest and have the most detail.
在至此我们了解了设计模式给了我们什么,但我们还没有讨论它带走了什么,这实际上非常重要。坦率地说,如果设计模式按设计实现,可能会有一些事情是不可能的。在这些情况下,了解需要做出哪些牺牲才能获得依赖设计模式带来的好处非常重要。这里的可能性是多种多样的,从功能限制(例如,不可能将数据直接作为下载导出到网络浏览器中的用户)到增加的复杂性(例如,需要更多的输入来描述您要将数据发送到哪里),甚至更多的技术方面,如数据一致性(例如,您可以看到可能有点陈旧的数据,但您不能确定),
At this point we understand what a design pattern gives us, but we’ve yet to discuss what it takes away, which is actually pretty important. Put bluntly, there may simply be things that are not possible if the design pattern is implemented as designed. In these cases it’s very important to understand what sacrifices are necessary in order to achieve the benefits that come from relying on a design pattern. The possibilities here are quite varied, ranging from functional limitations (e.g., it’s impossible to export data directly as a download to the user in a web browser) to increased complexity (e.g., it’s much more typing to describe where you want to send your data), and even to more technical aspects like data consistency (e.g., you can see data that might be a bit stale, but you can’t know for sure), so the discussion can range from simple explanations to detailed exploration of the subtle limitations when relying on a particular design pattern.
此外,虽然给定的设计模式通常可能完美地适合问题空间,但肯定会出现足够接近但不完美的情况。在这些情况下,重要的是要了解依赖这个独特位置的设计模式会产生什么后果:不是错误的模式,但也不是完美的模式。本节将讨论像这样的轻微错位的后果。
Additionally, while a given design pattern may often fit the problem space perfectly, there will certainly be scenarios where it is a close enough fit but not quite perfect. In these cases it’s important to understand what consequences will arise by relying on a design pattern that is in this unique spot: not the wrong pattern, but not quite a perfect pattern either. This section will discuss the consequences of slight misalignments like this.
现在我们已经更好地掌握了 API 设计模式的结构和解释方式,让我们换个话题,看看这些模式在构建一个假定的应用程序时可能产生的差异。简单的应用程序接口。
Now that we’ve gotten a better grasp on how API design patterns will be structured and explained, let’s switch gears and look at the difference these patterns can make when building a supposedly simple API.
如果如果您不熟悉 Twitter,可以将它想象成一个可以与他人分享短消息的地方——仅此而已。认为整个业务建立在每个人都创建微小消息的基础上有点可怕,但显然这足以值得一家价值数十亿美元的科技公司。这里没有提到的是,即使是一个极其简单的概念,在表面之下也恰好隐藏着相当多的复杂性。为了更好地理解这一点,让我们首先探索 Twitter 的 API 可能是什么样子,我们将其称为 Twapi。
If you’re unfamiliar with Twitter, think of it like a place where you can share short messages with others—that’s it. It’s a little scary to think that an entire business is built on everyone creating tiny messages, but apparently that’s enough to merit a multi-billion dollar technology company. What’s not mentioned here is that even with an extremely simple concept, there happens to be quite a lot of complexity hiding underneath the surface. To better understand this, let’s begin by exploring what an API for Twitter might look like, which we’ll call Twapi.
和Twapi,我们的主要职责是允许人们发布新消息和查看其他人发布的消息。从表面上看,这看起来很简单,但正如您可能猜到的那样,我们需要注意一些隐藏的陷阱。让我们首先假设我们有一个简单的 API 调用来创建 Twapi 消息。之后,我们将查看此 API 可能需要的两个额外操作:列出大量消息并将所有消息导出到不同的存储系统。
With Twapi, our primary responsibility is to allow people to post new messages and view messages posted by other people. On the surface this looks pretty simple, but as you might guess, there are a few hidden pitfalls for us to be aware of. Let’s start by assuming that we have a simple API call to create a Twapi message. After that, we’ll look at two additional actions this API might need: listing lots of messages and exporting all messages to a different storage system.
在我们开始之前,有两件重要的事情需要考虑。首先,这只是一个示例 API。这意味着重点将放在我们定义接口的方式上,而不是实现的实际工作方式上。在编程术语中,这有点像说我们将只讨论函数定义,而函数体留待以后填写。其次,这将是我们首次研究 API 定义。如果您还没有浏览过“关于本书”部分,那么现在可能是浏览的好时机,所以 TypeScript 风格的格式就不会太令人惊讶了。
Before we get moving, there are two important things to consider. First, this will be an example API only. That means that the focus will remain on the way in which we define the interface and not on how the implementation actually works. In programming terms, it’s a bit like saying we’ll only talk about the function definition and leave the body of the function to be filled in later. Second, this will be our first foray into looking at an API definition. If you haven’t looked through the “About this book” section, now might be a good time to do that so the TypeScript-style format isn’t too surprising.
现在这些事情都已经解决了,让我们看看我们将如何列出一些 Twapi消息。
Now that those things are out of the way, let’s look at how we’re going to be listing some Twapi messages.
如果我们可以创建消息,我们想要列出我们创建的那些消息似乎很合理。此外,我们希望看到我们的朋友创建的消息,更进一步,我们可能希望看到一长串消息,它是我们朋友的热门消息的优先集合(有点像新闻提要) ). 让我们从定义一个简单的 API 方法开始,完全不依赖于任何设计模式。
If we can create messages, it seems pretty reasonable that we’ll want to list those messages we created. Additionally, we’ll want to see the messages created by our friends, and taking this a step further, we might want to see a long list of messages that is a prioritized collection of our friends’ popular messages (sort of like a news feed). Let’s start by defining a simple API method to do this, relying on no design patterns at all.
开始从一开始,我们需要发送一个要求列出一堆消息的请求。为此,我们需要知道我们想要谁的消息,我们称之为“父母”。作为响应,我们希望我们的 API 发回一个简单的消息列表。图 2.1 概述了这种相互作用。
Starting from the beginning, we need to send a request asking to list a bunch of messages. To do this, we need to know whose messages we want, which we’ll call a “parent.” In response, we want our API to send back a simple list of messages. This interaction is outlined in figure 2.1.
Figure 2.1 Simple flow of requesting Twapi messages
现在我们了解了列出这些消息所涉及的流程,让我们将其形式化为实际的 API 定义。
Now that we understand the flow involved in listing these messages, let’s formalize it into an actual API definition.
Listing 2.1 Example API that lists Twapi messages
abstract class Twapi { ❶ static version = "v1"; ❷ static title = "Twapi API"; @get("/{parent=users/*}/messages") ❸ ListMessages(req: ListMessagesRequest): ListMessagesResponse; ❹ } interface ListMessagesRequest { parent: string; ❺ } interface ListMessagesResponse { results: Message[]; ❻ }
❶首先,我们将API服务定义为一个抽象类。这只是定义为 TypeScript 函数的 API 方法的集合。
❶ First, we define the API service as an abstract class. This is simply a collection of API methods defined as TypeScript functions.
❷我们可以使用 TypeScript 静态变量来存储 API 的元数据,例如名称或版本。
❷ We can use TypeScript static variables to store metadata about the API, such as the name or version.
❸在这里,我们依靠特殊的包装函数来定义应该映射到该函数的 HTTP 方法 (GET) 和 URL 模式 (/users/<user-id>/messages)。
❸ Here we rely on special wrapper functions to define the HTTP method (GET) and URL pattern (/users/<user-id>/messages) that should map to this function.
❹这里的 ListMessages 函数接受一个 ListMessagesRequest 并返回一个 ListMessagesResponse。
❹ Here the ListMessages function accepts a ListMessagesRequest and returns a ListMessagesResponse.
❺ ListMessagesRequest 有一个参数:parent。这是我们要列出的邮件的所有者。
❺ The ListMessagesRequest takes a single parameter: the parent. This is the owner of the messages that we’re trying to list.
❻ ListMessagesResponse 返回提供的用户拥有的消息的简单列表。
❻ The ListMessagesResponse returns a simple list of the messages the provided user owns.
如您所见,这个 API 定义非常简单。它接受单个参数并返回匹配消息列表。但是让我们想象一下,我们将它部署为我们的 API,并考虑随着时间的推移可能出现的最大问题之一:大量数据。
As you can see, this API definition is pretty simple. It accepts a single parameter and returns a list of matching messages. But let’s imagine that we deploy it as our API and consider one of the biggest problems that will likely arise as time goes on: large amounts of data.
随着越来越多的人使用该服务,此消息列表可能会变得很长。这可能不是什么大问题,因为响应开始有数十条或数百条消息,但是当您开始收到数千条、数十万条甚至数百万条消息时呢?一个 HTTP 响应携带 500,000 条消息,每条消息最多 140 个字符,这意味着这可能是高达 70 兆字节的数据!对于普通的 API 用户来说,这似乎很麻烦,更不用说单个 HTTP 请求会导致 Twapi 数据库服务器发送 70 兆字节的数据了。
As more and more people use the service, this list of messages can start to get pretty long. This might not be a big deal as responses start having tens or hundreds of messages, but what about when you start getting into the thousands, hundreds of thousands, or even millions? A single HTTP response carrying 500,000 messages, with each message up to 140 characters, means that this could be up to 70 megabytes of data! That seems pretty cumbersome for a regular API user to deal with, not to mention the fact that a single HTTP request will cause the Twapi database servers to send down 70 megabytes of data.
所以,我们能做些什么?显而易见的答案是允许 API 将可能变得非常大的响应分解成更小的部分,并允许用户一次请求一个块的所有消息。为此,我们可以依靠分页模式(见第 26 章)。
So what can we do? The obvious answer is to allow the API to break what could become very large responses into smaller pieces and allow users to request all the messages one chunk at a time. To do that, we can rely on the pagination pattern (see chapter 26).
作为我们将在第 26 章中了解到,分页模式是一种在更小、更易于管理的数据块中检索一长串项目的方法,而不是一次发送整个列表。该模式依赖于请求和响应中的额外字段;然而,这些看起来应该很简单。这种模式的一般流程如图 2.2 所示。
As we’ll learn in chapter 26, the pagination pattern is a way of retrieving a long list of items in smaller, more manageable chunks of data rather than the entire list being sent at once. The pattern relies on extra fields on both the request and response; however, these should seem pretty simple. The general flow of this pattern is shown in figure 2.2.
Figure 2.2 Example flow of the pagination pattern to retrieve Twapi messages
Here’s what this looks like in an actual API definition.
清单 2.2 列出带有分页的 Twapi 消息的示例 API
Listing 2.2 Example API that lists Twapi messages with pagination
abstract class Twapi { static version = "v1"; static title = "Twapi API"; @get("/{parent=users/*}/messages") ListMessages(req: ListMessagesRequest): ListMessagesResponse; ❶ } interface ListMessagesRequest { parent: string; pageToken: string; ❷ maxPageSize: number?; ❸ } interface ListMessagesResponse { results: Message[]; nextPageToken: string; ❹ }
❶ Notice that the method definition stays the same; no changes necessary here.
❷为了阐明我们要求的数据块(或页面),我们在请求中包含一个页面标记参数。
❷ To clarify which chunk (or page) of data we’re asking for, we include a page token parameter to the request.
❸我们还提供了一种方法来指定 Twapi 服务器应该在单个块中返回给我们的最大消息数。
❸ We also include a way to specify the maximum number of messages that the Twapi server should return to us in a single chunk.
❹来自 Twapi 服务器的响应将包括获取下一个消息块的方法。
❹ The response from the Twapi server will include a way to fetch the next chunk of messages.
What happens if we don’t start with the pattern?
和通过对 API 服务的这些小改动,我们实际上已经整合了一个 API 方法,该方法能够在发布的 Twapi 消息数量激增的情况下幸存下来,但这留下了一个明显的问题未得到解答:我为什么要费心遵循这种模式开始?为什么不在出现问题时才添加这些字段呢?换句话说,我们为什么要费心去修理还没有坏掉的东西呢?正如我们稍后在探索向后兼容性时将了解到的那样,原因很简单,就是为了避免导致现有软件崩溃。
With these small changes to the API service, we’ve actually put together an API method that is able to survive a surge in growth in the number of Twapi messages posted, but this leaves an obvious question unanswered: Why should I bother following this pattern from the start? Why not just add these fields later on when it becomes a problem? In other words, why should we bother to fix something that isn’t broken yet? As we’ll learn later when we explore backward compatibility, the reason is simply to avoid causing existing software to break.
在这种情况下,从我们更简单的原始设计(在单个响应中发送回所有数据)更改为依赖分页模式(将数据拆分为更小的块)可能看起来像是一个无害的更改,但它实际上会导致任何以前现有软件无法正常运行。在这种情况下,现有代码期望单个响应包含所有请求的数据,而不是其中的一部分。结果,有两个大问题。
In this case, changing from our more simple original design (sending back all the data in a single response) to relying on the pagination pattern (splitting the data into smaller chunks) might look like an innocuous change, but it would actually cause any previously existing software to function incorrectly. In this scenario, existing code would expect a single response to contain all the data requested rather than a portion of it. As a result, there are two big problems.
首先,因为之前编写的软件希望所有数据都在一个请求中返回,所以它无法找到显示在后续页面上的数据。结果,在更改之前编写的代码实际上与除了第一块数据之外的所有数据都被切断了,这导致我们遇到第二个问题。
First, because the previously written software expects all data to come back in a single request, it has no way of finding data that shows up on subsequent pages. As a result, code written before the change is effectively cut off from all but the first chunk of data, which leads us to the second problem.
由于现有消费者不知道如何获取额外的数据块,他们留下的印象是尽管只有一小块数据,但他们已经获得了所有数据。这种误解会导致很难发现的错误。例如,尝试计算某些数据的平均值的消费者最终可能会得到一个看起来准确但实际上只是返回的第一块数据的平均值的值。显然,这可能会导致不正确的值,但不会产生明显的错误。因此,此错误可能会在相当长的一段时间内未被发现。
Since existing consumers have no idea how to get additional chunks of data, they are left with the impression that they’ve been given all the data despite having only a small piece. This misunderstanding can lead to mistakes that can be very difficult to detect. For example, consumers trying to compute an average value of some data may end up with a value that might look accurate but is actually only an average of the first chunk of data returned. This, obviously, will likely lead to an incorrect value but won’t create an obvious error. As a result, this bug could go undetected for quite some time.
现在我们已经查看了列出消息的示例,让我们探讨一下为什么我们可能希望在以下情况下使用设计模式出口数据。
Now that we’ve looked at the example of listing messages, let’s explore why we might want to use a design pattern when exporting data.
在某些时候,Twapi 服务的用户可能希望能够导出他们所有的消息。与列出消息类似,我们首先必须考虑到我们需要导出的数据量可能会变得非常大(可能是数百 MB)。此外,与列表消息不同的是,我们应该考虑到在这些数据的接收端可能有许多不同的存储系统,理想情况下,我们有办法在新系统变得流行时与它们集成。此外,在导出数据之前,我们可能希望对数据应用许多不同的转换,例如加密、压缩或根据需要匿名化数据的某些部分。最后,所有这些不太可能以同步的方式很好地工作,这意味着我们需要一种方式来表达有一些待处理的工作(即,
At some point, users of the Twapi service may want the ability to export all of their messages. Similar to listing messages, we first must consider that the amount of data we need to export might become pretty large (possibly hundreds of MB). Additionally, and unlike listing messages, we should take into account that there may be lots of different storage systems on the receiving end of this data, and ideally we’d have a way to integrate with new ones as they become popular. Further, there could be lots of different transformations we may want to apply to the data before exporting it, such as encrypting it, compressing it, or anonymizing some pieces of it as needed. Finally, all of this is unlikely to work well in a synchronous fashion, meaning we need a way of expressing that there is some pending work (i.e., the actual exporting of the data) that is running in the background for the consumer to monitor the progress.
让我们首先为这个 API 提出一个简单的实现,然后看看将来可能出现的各种问题。
Let’s start by coming up with a simple implementation for this API and look at some of the various issues that might arise in the future.
作为注意,我们有几个主要关注点:大量数据、数据的最终目的地、数据的各种转换或配置(例如,压缩或加密),最后是 API 的异步特性。由于我们只是试图获得一个基本的 API 来导出我们的 Twapi 消息,涵盖大多数这些因素的最简单的选择是触发生成一个压缩文件以供将来下载。简而言之,当有人向此 API 发出请求时,响应实际上并不包含数据本身。相反,它包含一个指针,指向未来某个时间点可能下载数据的位置。
As noted, we have a few main concerns: large amounts of data, the final destination of the data, the various transformations or configurations of the data (e.g., compression or encryption), and finally the asynchronous nature of the API. Since we’re just trying to get out a basic API for exporting our Twapi messages, the simplest option that covers the majority of these factors is to trigger the generation of a compressed file to be downloaded in the future. In short, when someone makes a request to this API, the response doesn’t actually contain the data itself. Instead, it contains a pointer to where the data might be downloaded at some point in the future.
Listing 2.3 Simple API to export messages
abstract class Twapi { static version = "v1"; static title = "Twapi API"; @post("/{parent=users/*}/messages:export") ❶ ExportMessages(req: ExportMessagesRequest): ExportMessagesResponse; ❷ } interface ExportMessagesRequest { // The parent user of the messages to be exported. parent: string; ❸ } interface ExportMessagesResponse { // The location of a compressed file // containing the messages requested. exportDownloadUri: string; ❹ }
❶我们映射到的 URI 使用 POST HTTP 动词,以及一种特殊语法来传达这是正在执行的特殊操作,而不是标准 REST 操作之一。
❶ The URI that we map to uses the POST HTTP verb, as well as a special syntax to convey that this is a special action being performed, not one of the standard REST actions.
❷就像我们之前的例子一样,我们依赖于一个 ExportMessages 函数,它接受一个请求并返回一个响应。
❷ Just like our previous examples, we rely on an ExportMessages function, which takes a request and returns a response.
❸我们只想允许一次导出一个用户的数据,因此导出方法的范围仅限于一个父级(用户)。
❸ We only want to allow exporting data for one single user at a time, so the export method is scoped to a single parent (user).
❹来自 Twapi 服务器的响应指定了稍后从简单文件服务器而非此 API 服务下载的压缩文件的位置。
❹ The response from the Twapi server specifies the location of the compressed file to be downloaded later on from a simple file server rather than this API service.
该 API 确实完成了主要任务(导出数据)和一些次要任务(异步检索),但遗漏了几个重要方面。首先,我们无法定义有关所涉及数据的额外配置。例如,我们没有机会选择压缩格式或加密数据时要使用的密钥和算法。接下来,我们无法选择数据的最终目的地。相反,我们只是被告知以后可以去哪里寻找它。最后,如果我们仔细观察,就会发现接口的异步特性只是部分有用:虽然服务异步返回一个我们可以下载数据的位置,但我们无法监控导出的进度手术,
This API does accomplish the primary task (exporting the data) and several of the secondary tasks (asynchronous retrieval) but misses a few important aspects. First, we have no way to define extra configuration about the data involved. For example, we didn’t get a chance to choose the compression format or the keys and algorithm to use when encrypting the data. Next, we weren’t able to choose the final destination of the data. Instead, we were simply told where we might go looking for it later. Finally, if we look more closely, it becomes clear that the asynchronous nature of the interface is only partially useful: while the service does asynchronously return a location from which we can download the data, we have no way to monitor the progress of the export operation, nor a way to abort the operation in the case that we’re no longer interested in the data.
让我们看看我们是否可以通过使用稍后在我们的设计模式目录中定义的一些设计模式来改进这个设计,主要集中在导入/导出图案。
Let’s see if we can improve on this design by using a few of the design patterns later defined in our catalog of design patterns, primarily focusing on the import/export pattern.
作为我们将在第 28 章中了解到,导入/导出模式旨在解决这样的问题:我们的 API 服务中有一定数量的数据,而消费者想要一种将其导出(或导入)的方法。然而,与我们之前讨论的分页模式不同,该模式将依赖于其他模式,例如长时间运行的操作模式(在第 13 章中讨论)来完成工作。让我们从定义 API 开始,然后更仔细地研究每个部分如何协同工作。就像以前一样,请记住,我们不会深入探讨模式每个方面的所有细节,而是会尝试给出相关部分的高级视图。
As we’ll learn in chapter 28, the import/export pattern is aimed at problems just like this one: we have some amount of data in our API service and consumers want a way of getting it out (or in). However, unlike the pagination pattern we discussed earlier, this pattern will rely on others, such as the long-running operations pattern (discussed in chapter 13), to get the job done. Let’s start by defining the API and then looking more closely at how each piece works together. Just like before, keep in mind that we won’t go into all the details of every aspect of the pattern, but instead will try to give a high-level view of the relevant pieces.
Listing 2.4 API to export messages using a design pattern
abstract class Twapi { static version = "v1"; static title = "Twapi API"; @post("/{parent=users/*}/messages:export") ExportMessages(req: ExportMessagesRequest): Operation<ExportMessagesResponse, ExportMessagesMetadata>; ❶ } interface ExportMessagesRequest { // The parent user of the messages to be exported. parent: string; outputConfig: MessageOutputConfig; ❷ } interface MessageOutputConfig { destination: Destination; ❸ compressionConfig?: CompressionConfig; ❹ encryptionConfig?: EncryptionConfig; } interface ExportMessagesResponse { outputConfig: MessageOutputConfig; ❺ } interface ExportMessagesMetadata { // An integer between 0 and 100 // representing the progress of the operation. progressPercent: number; ❻ }
❶与前面的示例不同,我们的 ExportMessages 方法的返回类型是一个长时间运行的操作,它在完成时返回一个 ExportMessagesResponse 并使用 ExportMessagesMetadata 接口报告有关该操作的元数据。
❶ Unlike the previous examples, the return type of our ExportMessages method is a long-running operation, which returns an ExportMessagesResponse upon completion and reports metadata about the operation using the ExportMessagesMetadata interface.
❷除了父级(用户),ExportMessagesRequest 还接受一些关于结果输出数据的额外配置。
❷ In addition to the parent (user), the ExportMessagesRequest also accepts some extra configuration about the resulting output data.
❸在这里,我们定义了“目的地”,它表示操作完成时数据应该在哪里结束。
❸ Here we define the “destination,” which says where the data should end up when the operation completes.
❹此外,我们可以使用单独的配置对象调整数据压缩或加密的方式。
❹ Additionally, we can tweak the way in which the data is compressed or encrypted using separate configuration objects.
❺ The result will echo the configuration used when outputting the data to the resulting destination.
❻ ExportMessagesMetadata 将包含有关操作的信息,例如进度(百分比)。
❻ The ExportMessagesMetadata will contain information about the operation, such as the progress (as a percentage).
这种模式有什么了不起?首先,依靠封装的输出配置接口,我们能够在请求时接受各种参数,然后在我们的响应中重复使用相同的内容作为对消费者的确认。接下来,在这个配置中,我们可以定义几个不同的配置选项,我们将在清单 2.5 中更详细地查看这些选项。最后,我们能够使用长时间运行的操作的元数据信息跟踪导出操作的进度,该信息以百分比形式存储操作进度(0% 表示“未开始”,100% 表示“完成” ).
What’s so great about this pattern? First, by relying on an encapsulated output configuration interface, we’re able to accept the various parameters at request-time and then reuse this same content in our response back as confirmation to the consumer. Next, inside this configuration we’re able to define several different configuration options, which we’ll look at in more detail in listing 2.5. Finally, we’re able to keep track of the progress of the export operation using the long-running operation’s metadata information, which stores the progress of the operation as a percentage (0% being “not started” and 100% being “complete”).
综上所述,您可能已经注意到我们在之前的 API 定义中使用的一些构建块没有定义。让我们在提供一些示例配置的同时准确定义它们的外观。
All that said, you may have noticed that a few of the building blocks we used in the previous API definition weren’t defined. Let’s define exactly how those look while offering a few example configurations.
Listing 2.5 Interfaces for configuring the destinations and configurations
interface Destination { typeId: string; } interface FileDestination extends Destination { ❶ // The uploaded file will be hosted on our servers. fileName: string; } interface AmazonS3Destination extends Destination { ❷ // This requires write access to the bucket // and object prefix granted to // AWS Account ID 1234-5678-1234. uriPrefix: string; } interface CompressionConfig { typeId: string; } interface GzipCompressionConfig { ❸ // An integer value between 1 and 9. compressionLevel: number; } interface EncryptionConfig { ❹ // All sorts of encryption configuration here // can go here, or this can be // extended as CompressionConfig is. }
❶就像原来的例子一样,这里我们可以定义一个文件目的地,它将输出放在一个文件服务器上,以便稍后下载。
❶ Just like the original example, here we can define a file destination, which puts the output on a file server to be downloaded later.
❷除了文件下载示例,我们还可以请求将数据存储在亚马逊的简单存储服务(S3)上的某个位置。
❷ In addition to the file download example, we can also request that data be stored in a location on Amazon’s Simple Storage Service (S3).
❸在这里,我们定义了提供的各种压缩选项以及每个选项的配置值。
❸ Here we define the various compression options offered and the configuration values for each.
❹我们可以在单个界面中定义所有加密配置选项,也可以为各种选择使用相同的子类结构(如压缩所示)。
❹ We can either define all encryption configuration options in a single interface or use the same sub-classing structure (as shown with compression) for the various choices.
在这里我们可以看到定义配置选项的各种方式,例如数据的目的地或数据应如何压缩。唯一剩下的就是了解这个长时间运行的操作到底是如何工作的。我们将在第 28 章中更详细地探讨这种模式,但现在,让我们抛出这些接口的简单 API 定义,以便我们至少对它们的作用有一个高层次的理解G。
Here we can see the various ways of defining the configuration options such as destinations for data or how the data should be compressed. The only thing left is to understand how exactly this long-running operation stuff actually works. We’ll explore this pattern in much more detail in chapter 28, but for now, let’s just throw out a simple API definition of these interfaces so that we have at least a high-level understanding of what they’re doing.
Listing 2.6 Definition of common error and long-running operation interfaces
interface OperationError { ❶ code: string; message: string; details?: any; } interface Operation<ResultT, MetadataT> { ❷ id: string; done: boolean; result?: ResultT | OperationError; metadata?: MetadataT; }
❶我们需要的一个基本组件是错误的概念,它(至少)是错误代码和消息。它还可能包含一个可选字段,其中包含有关错误的更多详细信息。
❶ One basic component we’ll need is a concept of an error, which is (at least) an error code and a message. It may also include an optional field with lots more detail about the error.
❷长时间运行的操作是一个类似于 promise 的结构,根据结果和元数据类型进行参数化(就像 Java 泛型)。
❷ A long-running operation is a promise-like structure, parameterized based on the result and metadata types (just like Java Generics).
与特定于导出消息的其他接口不同,这些 (Error和Operation) 更通用,可以在整个 API 中共享。换句话说,将这些更像是常见的构建块而不是特定于特定的构建块是安全的功能。
Unlike the other interfaces that are specific to exporting messages, these (Error and Operation) are more general and can be shared across the entire API. In other words, it’s safe to think of these more like common building blocks than specific to a particular function.
What happens if we don’t start with the pattern?
不像在我们之前的示例中,模式驱动和非模式驱动的选项看起来非常接近,在这种情况下,这两个选项在生成的 API 表面上存在显着差异。因此,这个问题的答案很明确:如果您发现自己需要所提供的功能(不同的导出目的地、单独的配置等),那么从非模式驱动的方法开始将会为消费者带来重大变化。通过从模式驱动的方法开始解决这个问题,API 将像新的一样优雅地发展功能性是需要。
Unlike our previous example where the pattern-driven and non-pattern-driven options look pretty close to one another, in this scenario the two options differ significantly in the resulting API surface. As a result, the answer to this question is clear: if you find yourself needing the functionality provided (different export destinations, separate configurations, etc.) then starting in a non-pattern-driven approach will result in breaking changes for consumers. By starting with a pattern-driven approach for this problem, the API will evolve gracefully as new functionality is needed.
API design patterns are sort of like adaptable blueprints for designing and structuring APIs.
API 设计模式很重要,因为 API 通常非常“严格”,因此不容易更改,因此设计模式有助于最大限度地减少对大型结构更改的需求。
API design patterns are important because APIs are generally very “rigid” and therefore not easy to change, so design patterns help minimize the need for large structural changes.
在本书中,API 设计模式将分为几个部分,包括名称和摘要、建议的规则、动机、概述、实现以及使用提供的模式而不是自定义模式的权衡备择方案。
Throughout this book, API design patterns will have several sections, including the name and summary, suggested rules, motivation, overview, implementation, and trade-offs of using the provided pattern over custom alternatives.
并非我们 API 设计工具箱中的所有工具都是设计模式。在接下来的几章中,我们将探讨 API 设计中的一些主题,它们不完全符合设计模式(如第 2 章中所定义),但对于指导我们构建良好 API 的旅程仍然很重要且有帮助(如第 1 章所定义)。
Not all tools in our API design toolbox will be design patterns. In the next few chapters, we’ll look at some topics in API design that don’t quite qualify as design patterns (as defined in chapter 2), but are still important and helpful for guiding us on our journey toward building good APIs (as defined in chapter 1).
在第 3 章中,我们将查看为 API 的不同组件命名的指南。接下来,在第 4 章中,我们将介绍如何在逻辑上安排这些组件并定义它们之间的关系。最后,在第 5 章中,我们将了解如何在 API 中可能需要的不同字段的多种可用数据类型之间做出决定。
In chapter 3, we’ll look at guidelines for naming the different components of our APIs. Next, in chapter 4, we’ll cover how to logically arrange these components and define their relationships with one another. Finally, in chapter 5, we’ll look at how to decide between the many data types available for the different fields we might need in our APIs.
不管我们喜欢与否,名字无处不在。在我们构建的每个软件系统中,以及我们设计或使用的每个 API 中,每个角落都隐藏着名字,它们的寿命将比我们预期的要长得多。因此,选择好名字的重要性似乎显而易见(即使我们并不总是尽可能多地考虑我们的命名选择)。在本章中,我们将探索我们必须命名的 API 的不同组件、我们可以用来选择好名字的一些策略、区分好名字和坏名字的高级属性,以及最后的一些一般原则在我们不可避免地遇到艰难的命名决定时帮助指导我们。
Whether we like it or not, names follow us everywhere. In every software system we build, and every API we design or use, there are names hiding around each corner that will live far longer than we ever intend them to. Because of this, it should seem obvious that it’s important to choose great names (even if we don’t always give our naming choices as much thought as we should). In this chapter, we’ll explore the different components of an API that we’ll have to name, some strategies we can employ to choose good names, the high-level attributes that distinguish good names from bad ones, and finally some general principles to help guide us when making tough naming decisions that we’ll inevitably run into.
在一般来说,在软件工程领域,避免为事物选择名称几乎是不可能的。如果可能的话,我们需要能够编写仅使用语言关键字的代码块(例如,class,for, 要么if), 这充其量是不可读的。考虑到这一点,编译软件是一个特例。这是因为对于传统的编译代码,我们的函数和变量的名称只对那些有权访问源代码的人来说很重要,因为名称本身通常在编译(或缩小)和部署期间消失。
In the world of software engineering generally, it’s practically impossible to avoid choosing names for things. If that were possible, we’d need to be able to write chunks of code that used only language keywords (e.g., class, for, or if), which would be unreadable at best. With that in mind, compiled software is a special case. This is because with traditional compiled code, the names of our functions and variables are only important to those who have access to the source code, as the name itself generally disappears during compilation (or minification) and deployment.
另一方面,在设计和构建 API 时,我们选择的名称更为重要,因为 API 的所有用户都会看到并与之交互。换句话说,这些名字不会简单地被编译掉并从世界上隐藏起来。这意味着我们需要对我们为 API 选择的名称进行大量的思考和考虑。
On the other hand, when designing and building an API, the names we choose are much more important, as they’re what all the users of the API will see and interact with. In other words, these names won’t simply get compiled away and hidden from the world. This means we need to put an extraordinary amount of thought and consideration into the names we choose for an API.
这里明显的问题变成了,“如果他们被证明是错误的选择,我们就不能改变名字吗?” 正如我们将在第 24 章中了解到的,在 API 中更改名称可能非常具有挑战性。想象一下,更改源代码中常用函数的名称,然后意识到您需要进行大量查找和替换,以确保更新对该函数名称的所有引用。虽然不方便(在某些 IDE 中甚至很容易),但这当然是可能的。但是,请考虑此源代码是否可供公众构建到他们自己的项目中。即使您可以以某种方式更新所有可用公共源代码的所有引用,总会有您无权访问因此无法更新的私有源代码。
The obvious question here becomes, “Can’t we just change the names if they turn out to be bad choices?” As we’ll learn in chapter 24, changing names in an API can be quite challenging. Imagine changing the name of a frequently used function in your source code and then realizing you need to do a big find-and-replace to make sure you updated all references to that function name. While inconvenient (and even easy in some IDEs), this is certainly possible. However, consider if this source code was available to the public to build into their own projects. Even if you could somehow update all references for all public source code available, there is always going to be private source code that you don’t have access to and therefore cannot possibly update.
换句话说,在 API 中更改面向公众的名称有点像更改您的地址或电话号码。要在任何地方成功更改此号码,您必须联系所有知道您电话号码的人,包括您的祖母(她可能使用纸质地址簿)和所有有权使用该电话号码的营销公司。即使您有办法与知道您电话号码的每个人取得联系,您仍然需要他们完成更新联系信息的工作,而他们可能太忙而无暇顾及这些工作。
Put a bit differently, changing public-facing names in an API is a bit like changing your address or phone number. To successfully change this number everywhere, you’d have to contact everyone who ever had your phone number, including your grandmother (who might use a paper address book) and every marketing company that ever had access to it. Even if you have a way to get in touch with everyone who has your number, you’d still need them to do the work of updating the contact information, which they might be too busy to do.
既然我们已经看到了选择好名字(并避免更改它们)的重要性,这就引出了一个重要的问题:什么造就了一个名字“好的”?
Now that we’ve seen the importance of choosing good names (and avoiding changing them), this leads us to an important question: What makes a name “good”?
作为我们在第 1 章中了解到,当 API 具有可操作性、表现力、简单性和可预测性时,它们就是“好”的。另一方面,名称非常相似,只是它们不一定是可操作的(换句话说,名称实际上不做任何事情)。让我们看看这个属性子集和一些命名选择的例子,从表现力开始。
As we learned in chapter 1, APIs are “good” when they are operational, expressive, simple, and predictable. Names, on the other hand, are quite similar except for the fact that they aren’t necessarily operational (in other words, a name doesn’t actually do anything). Let’s look at this subset of attributes and a few examples of naming choices, starting with being expressive.
更多的比什么都重要,一个名字清楚地传达它所命名的东西是至关重要的。这个东西可能是函数或RPC (CreateAccount例如让读者清楚地知道这件事代表什么。这听起来可能很容易,但通常很难以全新的眼光看待一个名字,忘记我们通过长期在特定领域工作而建立的所有背景。这种上下文通常是一项巨大的资产,但在这种情况下,它更像是一种负担:它让我们不擅长命名事物。WeatherReadingpostal_addressColor.BLUE
More important than anything else, it’s critical that a name clearly convey the thing that it’s naming. This thing might be a function or RPC (e.g., CreateAccount), a resource or message (e.g., WeatherReading), a field or property (e.g., postal_address), or something else entirely, such as an enumeration value (e.g., Color.BLUE), but it should be clear to the reader exactly what the thing represents. This might sound easy, but it’s often very difficult to see a name with fresh eyes, forgetting all the context that we’ve built by working in a particular area over time. This context is a huge asset generally, but in this case it’s more of a liability: it makes us bad at naming things.
例如,术语主题经常用于异步消息传递的上下文中(例如,Apache Kafka 或 RabbitMQ);但是,它也用于机器学习和自然语言处理的特定领域,称为主题建模. 如果您要在机器学习 API 中使用术语主题,那么用户可能会对您指的是哪种主题感到困惑也就不足为奇了。如果这确实有可能(也许您的 API 同时使用异步消息传递和主题建模),您可能希望选择一个比 更具表现力的名称topic,例如model_topicormessaging_topic以防止用户困惑。
For example, the term topic is often used in the context of asynchronous messaging (e.g., Apache Kafka or RabbitMQ); however, it’s also used in a specific area of machine learning and natural language processing called topic modeling. If you were to use the term topic in your machine learning API, it wouldn’t be all that surprising that users might be confused about which type of topic you’re referring to. If that’s a real possibility (perhaps your API uses both asynchronous messaging and topic modeling), you might want to choose a more expressive name than topic, such as model_topic or messaging_topic to prevent user confusion.
尽管一个富有表现力的名字当然很重要,如果名字太长又没有增加清晰度,它也会变得很麻烦。使用之前的示例(topic,指的是计算机科学的多个不同领域),如果 API 仅指代异步消息传递(例如,类似于 Apache Kafka 的 API)并且与机器学习无关,那么topic就足够清晰和简单,但messaging_topic不会增加太多价值。简而言之,名称应该具有表现力,但仅限于名称的每个附加部分都增加价值以证明其存在的程度。
While an expressive name is certainly important, it can also become burdensome if the name is excessively long without adding additional clarity. Using the example from before (topic, referring to multiple different areas of computer science), if an API only ever refers to asynchronous messaging (e.g., an Apache Kafka–like API) and has nothing to do with machine learning, then topic is sufficiently clear and simple, while messaging_topic wouldn’t add much value. In short, names should be expressive but only to the extent that each additional part of a name adds value to justify its presence.
另一方面,名称也不应过于简单化。例如,假设我们有一个 API 需要存储一些用户指定的首选项。该资源可能被称为UserSpecifiedPreferences;然而,这Specified并没有给这个名字增加太多。另一方面,如果我们简单地调用 resource Preferences,则不清楚它们是谁的首选项,并且当需要存储和管理系统或管理员级别的首选项时,可能会导致混淆。在这种情况下,UserPreferences似乎是表达性名称和简单名称之间的最佳结合点,总结为表 3.1。
On the other hand, names shouldn’t be oversimplified either. For example, imagine we have an API that needs to store some user-specified preferences. The resource might be called UserSpecifiedPreferences; however, the Specified isn’t adding very much to the name. On the other hand, if we simply called the resource Preferences, it’s unclear whose preferences they are and could cause confusion down the line when there are system- or administrator-level preferences that need to be stored and managed. In this case, UserPreferences seems to be the sweet spot between an expressive name and a simple name, summarized in table 3.1.
Table 3.1 Choosing between simple and expressive names
现在我们已经在表达性和简单性之间取得了平衡,选择一个好名字的最后一个也是非常重要的方面:可预测性。想象一个 API,它使用名称topic将类似的异步消息组合在一起(类似于 Apache Kafka)。然后想象 APImessaging_topic在其他地方使用该名称,没有太多理由选择一个或另一个。这会导致一些非常令人沮丧和不寻常的情况。
Now that we’ve gone through the balance between expressive and simple, there’s one final and very important aspect of choosing a good name: predictability. Imagine an API that uses the name topic to group together similar asynchronous messages (similar to Apache Kafka). Then imagine that the API uses the name messaging_topic in other places, without much reason for choosing one or the other. This leads to some pretty frustrating and unusual circumstances.
Listing 3.1 Example frustrating code due to inconsistent naming
function handleMessage(message: Message) { if (message.topic == "budget.purge") { ❶ client.PurgeTopic({ messagingTopic: "budget.update" ❷ }); } }
❶ Here we use the name topic to read the topic of a given message.
❷这里我们使用名称 messagingTopic 来表示相同的概念。
❷ Here we use the name messagingTopic to represent the same concept.
在奇怪的情况下,这似乎并不令人沮丧,请考虑我们在这里违反的重要原则。一般来说,我们应该用相同的名称来表示相同的事物,不同的名称来表示不同的事物。如果我们将该原则视为公理化,就会引出一个重要问题:与 有何topic不同messagingTopic?毕竟我们用的名字不一样,代表的概念肯定不一样吧?
In the odd case that this doesn’t seem frustrating, consider an important principle we’re violating here. In general, we should use the same name to represent the same thing and different names to represent different things. If we take that principle as axiomatic, this leads to an important question: how is topic different from messagingTopic? After all, we used different names, so they must represent different concepts, right?
基本的潜在目标是允许 API 的用户学习一个名称并继续在该知识的基础上进行构建,以便能够预测未来的名称(例如,如果它们代表相同的概念)会是什么样子。topic当我们指的是“给定消息的主题”时(当我们指的是不同的东西时,通过在整个 API 中始终如一地使用),我们允许 API 的用户在他们已经学到的东西的基础上进行构建,而不是让他们感到困惑,并且迫使他们研究每一个名字,以确保它意味着他们所假设的。
The basic underlying goal is to allow users of an API to learn one name and continue building on that knowledge to be able to predict what future names (e.g., if they represent the same concept) would look like. By using topic consistently throughout an API when we mean “the topic for a given message” (and something else when we mean something different), we’re allowing users of an API to build on what they’ve already learned rather than confusing them and forcing them to research every single name to ensure it means what they would assume.
现在我们已经了解了好名字的一些特征,让我们探索一些通用指南,这些指南可以在 API 中命名时充当护栏,从语言、语法、和句法。
Now that we have an idea of some of the characteristics of good names, let’s explore some general guidelines that can act as guard rails when naming things in an API, starting with the fundamental aspects of language, grammar, and syntax.
虽然代码都是关于 1 和 0 的,从根本上存储为数字,但命名是我们使用语言表达的主要主观构造。编程语言对于什么是有效的什么是无效的有非常严格的规则,不同的是,语言已经发展到为人服务而不是计算机,从而使规则变得不那么严格。这使我们的命名选择更加灵活和模棱两可,这可能是好事也可能是坏事。
While code is all about ones and zeros, fundamentally stored as numbers, naming is a primarily subjective construct we express using language. Unlike programming languages, which have very firm rules about what’s valid and what’s not, language has evolved to serve people more than computers, making the rules much less firm. This allows our naming choices to be a bit more flexible and ambiguous, which can be both a good and bad thing.
一方面,歧义允许我们命名事物足够通用以支持未来的工作。例如,命名一个字段image_uri而不是jpeg_uri阻止我们将自己限制为单一图像格式 (JPEG)。另一方面,当有多种方式来表达同一事物时,我们通常倾向于互换使用它们,这最终使我们的命名选择变得不可预测(请参阅第 3.2.3 节)并导致令人沮丧和繁琐的 API。为了避免其中的一些,即使“语言”具有相当大的灵活性,通过强加一些我们自己的规则,我们可以避免失去我们在一个好的 API 中非常看重的可预测性。在本节中,我们将探讨一些与语言相关的简单规则,这些规则有助于最大限度地减少我们在命名事物时必须做出的一些随意选择。
On the one hand, ambiguity allows us to name things to be general enough to support future work. For example, naming a field image_uri rather than jpeg_uri prevents us from limiting ourselves to a single image format (JPEG). On the other hand, when there are multiple ways to express the same thing, we often tend to use them interchangeably, which ultimately makes our naming choices unpredictable (see section 3.2.3) and results in a frustrating and cumbersome API. To avoid some of this, even though “language” has quite a bit of flexibility, by imposing some rules of our own, we can avoid losing the predictability we value so highly in a good API. In this section, we’ll explore some of the simple rules related to language that can help minimize some of the arbitrary choices we’ll have to make when naming things.
尽管世界上有很多种语言,如果我们必须选择一种在软件工程中使用最多的语言,目前美国英语是领先的竞争者。这并不是说美式英语比其他语言好或差。然而,如果我们的目标是在全球范围内最大限度地实现互操作性,那么使用美式英语以外的任何语言都可能成为障碍而不是好处。
While there are many languages spoken in the world, if we had to choose a single language that was used the most in software engineering, currently American English is the leading contender. This isn’t to say that American English is any better or worse than other languages; however, if our goal is maximum interoperability across the world, using anything other than American English is likely to be a hindrance rather than a benefit.
这意味着应该使用英语语言概念(例如,BookStore而不是Librería),并且通常应该首选常见的美式拼写(例如,color而不是color)。这还有一个额外的好处,即几乎总是可以轻松地适应 ASCII 字符集,除了美式英语从其他语言借用的少数例外情况(例如,café)。
This means that English language concepts should be used (e.g., BookStore rather than Librería) and common American-style spellings should generally be preferred (e.g., color rather than colour). This also has the added benefit of almost always fitting comfortably into the ASCII character set, with a few exceptions where American English has borrowed from other languages (e.g., café).
这并不意味着 API 注释必须使用美式英语。如果 API 的受众完全位于法国,则提供法语文档(可能会或可能不会自动从 API 规范注释生成)可能是有意义的。但是,使用 API 的软件工程师团队可能会使用其他 API,这些 API 不太可能专门针对法国客户。因此,它仍然认为,即使 API 的受众不使用美国英语作为他们的主要语言,API 本身仍然应该依赖美国英语作为使用许多不同 API 的各方共享的通用语言一起。
This doesn’t mean that API comments must be in American English. If the audience of an API is based exclusively in France, it might make sense to provide documentation (which may or may not be automatically generated from API specification comments) in French. However, the team of software engineers consuming the API is likely to use other APIs, which are unlikely to be exclusively targeted toward customers in France. As a result, it still holds that even if the audience of an API doesn’t use American English as their primary language, the API itself should still rely on American English as a shared common language across all parties using lots of different APIs together.
鉴于API 将使用美国英语作为标准语言,这会打开很多复杂的蠕虫罐头,因为英语并不是最简单的语言,有许多不同的时态和语气。幸运的是,发音不会成为问题,因为源代码是一种书面语言而不是口头语言,但这并不一定能缓解所有潜在问题。
Given that an API will use American English as the standard language, this opens quite a few complicated cans of worms as English is not exactly the simplest of languages with many different tenses and moods. Luckily, pronunciation won’t be an issue as source code is a written rather than spoken language, but this doesn’t necessarily alleviate all the potential problems.
本节将讨论一些最常见的问题,而不是试图规定美国英语语法的每个方面,因为它适用于在 API 中命名事物。让我们从查看操作(例如,RPC 方法或 RESTful 动词)开始。
Rather than attempt to dictate every single aspect of American English grammar as it applies to naming things in an API, this section will touch on a few of the most common issues. Let’s start by looking at actions (e.g., RPC methods or RESTful verbs).
在任何 API,都会有一些等同于编程语言的“函数”的东西,它们完成 API 预期的实际工作。这可能是一个纯粹的 RESTful API,它仅依赖于特定的预设操作列表(获取、创建、删除等),那么您在这里不需要做太多事情,因为所有操作都将采用<StandardVerb><Noun>(例如,CreateBook)。对于允许使用非标准动词的非 RESTful 或面向资源的 API,我们在如何命名这些操作方面有更多选择。
In any API, there will be something equivalent to a programming language’s “functions,” which do the actual work expected of the API. This might be a purely RESTful API, which relies only on a specific preset list of actions (Get, Create, Delete, etc.), then you don’t have all that much to do here as all actions will take the form of <StandardVerb><Noun> (e.g., CreateBook). In the case of non-RESTful or resource-oriented APIs that permit nonstandard verbs, we have more choices for how we name these actions.
REST 标准动词有一个重要的共同点:它们都使用祈使语气。换句话说,它们都是动词的命令或命令。如果这没有多大意义,想象一下陆军中的一名教官对你大喊大叫:“创作那本书!” “删除那个天气读数!” “归档该日志条目!” 尽管这些命令对军队来说是荒谬的,但你确切地知道你应该做什么。
There is one important aspect that the REST standard verbs have in common: they all use the imperative mood. In other words, they are all commands or orders of the verb. If this isn’t making a lot of sense, imagine a drill sergeant in the Army shouting at you to do something: “Create that book!” “Delete that weather reading!” “Archive that log entry!” As ridiculous as these commands are for the Army, you know exactly what you’re supposed to do.
另一方面,有时我们编写的函数的名称可能带有指示性的意味。一个常见的例子是当一个函数正在调查某事时,例如String.IsNullOrEmpty()在 C# 中。在这种情况下,动词“to be”呈现指示语气(询问有关资源的问题)而不是命令语气(命令服务做某事)。
On the other hand, sometimes the names of the functions we write can take on the indicative mood. One common example is when a function is investigating something, such as String.IsNullOrEmpty() in C#. In this case, the verb “to be” takes on the indicative mood (asking a question about a resource) rather than the imperative mood (commanding a service to do something).
虽然我们的函数采用这种方式并没有什么根本性的错误,但当在 Web API 中使用时,它会留下一些未解决的重要问题。首先,对于看起来不需要请求远程服务就可以处理的东西,“是吗?isValid()实际上是远程调用还是本地处理?” 虽然我们希望用户假设所有方法调用都通过网络进行,但让看似无状态的调用这样做有点误导。
While there’s nothing fundamentally wrong with our functions taking on this mood, when used in a web API it leaves a few important questions unanswered. First, with something that looks like it can be handled without asking a remote service, “Does isValid() actually result in a remote call or is it handled locally?” While we hope that users assume all method calls are going over the network, it’s a bit misleading to have what appears to be a stateless call do so.
其次,响应应该是什么样的?以名为isValid(). 它应该返回一个简单的布尔字段来说明输入是否有效吗?如果该输入无效,它是否应该返回错误列表?另一方面,GetValidationErrors()更清楚:如果输入完全有效则返回空列表,否则返回错误列表。对于响应的形状没有真正的混淆拿。
Secondly, what should the response look like? Take the case of an RPC called isValid(). Should it return a simple Boolean field stating whether the input was valid? Should it return a list of errors if that input wasn’t valid? On the other hand, GetValidationErrors() is more clear: either it returns an empty list if the input is completely valid or a list of errors if it isn’t. There’s no real confusion about the shape the response will take.
其他选择名称时的混淆区域集中在介词上,例如“with”、“to”或“for”。虽然这些词在日常对话中非常有用,但在 Web API 的上下文中使用时,尤其是在资源名称中,它们可能表示 API 存在更复杂的潜在问题。
Another area of confusion when choosing names centers on prepositions, such as “with,” “to,” or “for.” While these words are very useful in everyday conversation, when used in the context of a web API, particularly in resource names, they can be indicative of more complicated underlying problems with the API.
例如,库 API 可能有一种列出Book资源的方法。如果此 API 需要一种方法来列出Book资源并包括Author负责该书的资源,则可能很想为此组合创建一个新资源:(BookWithAuthor然后通过调用ListBooksWithAuthors或类似的方式列出)。乍一看这似乎很好,但是当我们需要列出嵌入Book资源的资源时怎么办?Publisher还是两者Author兼而有之Publisher?在不知不觉中,根据我们想要的不同相关资源,我们将有 30 个不同的 RPC 可以调用。
For example, a Library API might have a way to list Book resources. If this API needed a way to list Book resources and include the Author resources responsible for that book, it may be tempting to create a new resource for this combination: BookWithAuthor (which would then be listed by calling ListBooksWithAuthors or something similar). This might seem fine at first glance, but what about when we need to list Book resources with the Publisher resources embedded? Or both Author and Publisher resources? Before we know it, we’ll have 30 different RPCs to call depending on the different related resources we want.
在这种情况下,我们想要在名称中使用的介词(“with”)表明了一个更基本的问题:我们想要一种列出资源并在响应中包含不同属性的方法。我们可能会使用字段掩码或视图(参见第 8 章)来解决这个问题,同时避免使用这个名称古怪的资源。在这种情况下,介词表示有时不太正确。所以即使介词可能不应该被完全禁止(例如,也许一个字段会被称为bits_per_second),这些棘手的小词有点像代码味道,暗示某些事情不太正确,值得进一步调查。
In this case, the preposition we want to use in the name (“with”) is indicative of a more fundamental problem: we want a way to list resources and include different attributes in the response. We might instead solve this using a field mask or a view (see chapter 8) and avoid this oddly-named resource at the same time. In this case, the preposition was an indication that sometimes wasn’t quite right. So even though prepositions probably shouldn’t be forbidden entirely (e.g., maybe a field would be called bits_per_second), these tricky little words act a bit like a code smell, hinting at something being not quite right and worth further investigation.
最多通常,我们会将 API 中的事物名称选择为单数形式,例如Book, Publisher, and Author(而不是Books, Publishers, or Authors)。此外,这些名称选择往往会通过 API 具有新的含义和用途。例如,一个Book资源可能在某处被一个名为Author.favoriteBook(见第 13 章)的字段引用。但是,当我们需要讨论这些资源的倍数时,事情有时会变得一团糟。更复杂的是,如果 API 使用 RESTful URL,一堆资源的集合名称几乎总是复数。例如,当我们请求单个Book资源时,URL 中的集合名称几乎肯定是类似/books/1234.
Most often, we’ll choose the names for things in our APIs to be the singular form, such as Book, Publisher, and Author (rather than Books, Publishers, or Authors). Further, these name choices tend to take on new meanings and purposes through the API. For example, a Book resource might be referenced somewhere by a field called Author.favoriteBook (see chapter 13). However, things can sometimes get messy when we need to talk about multiples of these resources. To make things more complicated, if an API uses RESTful URLs, the collection name of a bunch of resources is almost always plural. For example, when we request a single Book resource, the collection name in the URL will almost certainly be something like /books/1234.
对于我们用作示例的名称(例如,Book),这不是什么大问题;毕竟,提及多个Book资源只需要添加一个“s”来将名称复数化为Books. 然而,有些名字并不是那么简单。例如,假设我们正在为足病医生办公室(足科医生)制作一个 API。当我们有一个Foot资源时,我们需要打破这种只添加一个“s”的模式,从而形成一个feet集合。
In the case of the names we’ve used as examples (e.g., Book), this isn’t much of an issue; after all, mentioning multiple Book resources just involves adding an “s” to pluralize the name into Books. However, some names are not so simple. For example, imagine we’re making an API for a podiatrist’s office (a foot doctor). When we have a Foot resource, we’ll need to break this pattern of just adding an “s,” leading to a feet collection.
这个例子当然打破了模式,但至少它是清晰明确的。如果我们的 API 与人打交道并因此拥有Person资源怎么办?是收藏persons?或者people?换句话说,应该Person(id=1234)通过访问看起来像/persons/1234或/people/1234? 幸运的是,我们关于使用美式英语的指南给出了一个答案:使用人。
This example certainly breaks the pattern, but at least it’s clear and unambiguous. What if our API deals with people and therefore has a Person resource. Is the collection persons? Or people? In other words, should Person(id=1234) be retrieved by visiting a URL that looks like /persons/1234 or /people/1234? Luckily our guidelines about using American-style English prescribes an answer: use people.
其他情况更令人沮丧。例如,假设我们正在为水族馆开发 API。资源的集合是什么Octopus?如您所见,我们选择美式英语有时会反咬我们一口。不过,最重要的是我们选择了一种模式并坚持下去,这通常涉及快速搜索语法学家所说的正确内容(在这种情况下,“章鱼”完全没问题)。这也意味着我们永远不应该假设可以简单地通过添加“s”来创建资源的复数形式——这是寻找软件工程师的常见诱惑为了模式。
Other cases are more frustrating still. For example, imagine we are working on an API for the aquarium. What is the collection for an Octopus resource? As you can see, our choice of American English sometimes comes back to bite us. What’s most important though is that we choose a pattern and stick to it, which often involves a quick search for what the grammarians say is correct (in this case, “octopuses” is perfectly fine). This also means that we should never assume the plural of a resource can be created simply by adding an “s”—a common temptation for software engineers looking for patterns.
我们已经达到了命名的更多技术方面。与我们之前研究过的方面一样,在语法方面也有相同的指导方针。首先,选择一些东西并坚持下去。其次,如果存在现有标准(例如,美式英语拼写),请使用该标准。那么这在实际意义上意味着什么呢?让我们从案例开始。
We’ve reached the more technical aspects of naming. As with the previous aspects we’ve looked at, when it comes to syntax the same guidelines are in place. First, pick something and stick to it. Second, if there’s an existing standard (e.g., American English spellings), use that. So what does this mean in a practical sense? Let’s start with case.
什么时候我们定义了一个 API,我们需要命名各种组件,比如资源、RPC 和字段。对于其中的每一个,我们倾向于使用不同的大小写,这有点像呈现名称的格式。大多数情况下,这种呈现仅在多个单词如何串在一起形成一个词汇单元时才明显。例如,如果我们有一个字段代表一个人的名字,我们可能需要将该字段称为“名字”。然而,在几乎所有的编程语言中,空格都是词法分隔符,所以我们需要将“first name”组合成一个单元,这为许多不同的选项打开了大门,例如“camel case”,“snake case”, ”或“烤肉串”。
When we define an API, we need to name the various components, which are things like resources, RPCs, and fields. For each of these, we tend to use a different case, which is sort of like a format in which the name is rendered. Most often, this rendering is only apparent in how multiple words are strung together to make a single lexical unit. For example, if we had a field that represents a person’s given name, we might need to call that field “first name.” However, in almost all programming languages, spaces are the lexical separation character, so we need to combine “first name” into a single unit, which opens the door for lots of different options, such as “camel case,” “snake case,” or “kebab case.”
在驼峰式大小写中,单词是通过将第一个单词之后的所有单词的字母大写来连接的,因此“first name”将呈现为firstName(它的大写字母像驼峰一样)。在蛇的情况下,单词使用下划线字符连接,如first_name(看起来有点像蛇)。在 kebab 的情况下,单词是用连字符连接的,如first-name(看起来有点像烤肉串串起不同的单词)。根据用于表示 API 规范的语言,不同的组件在不同的情况下呈现。例如,在 Google 的 Protocol Buffer 语言中,消息(如 TypeScript 接口)的标准是使用大驼峰形式,如UserSettings(注意大写“U”)和蛇形形式的字段名称,如first_name. 另一方面,在开放 API 规范标准中,字段名称采用驼峰式大小写,如firstName.
In camel case, the words are joined by capitalizing the letters of all words after the first, so “first name” would render as firstName (which has capital letters as humps like a camel). In snake case, words are joined using underscore characters, as in first_name (which is meant to look a bit like a snake). In kebab case, words are joined with hyphen characters, as in first-name (which looks a bit like a kebab skewering the different words). Depending on the language used to represent an API specification, different components are rendered in different cases. For example, in Google’s Protocol Buffer language, the standard is for messages (like TypeScript interfaces) to use upper camel case, as in UserSettings (note the uppercase “U”) and snake case for field names, as in first_name. On the other hand, in open API specification standards, field names take on camel case, as in firstName.
如前所述,具体的选择并不那么重要,只要始终一致地使用这些选择即可。例如,如果您要使用user_ settings协议缓冲区 ( https://developers.google.com/protocol-buffers ) 消息的名称,很容易认为这实际上是一个字段名称而不是消息。因此,这可能会使使用该 API 的任何人感到困惑。说到类型,让我们花点时间看看 reserved字。
As noted earlier, the specific choice isn’t all that important so long as the choices are used consistently throughout. For example, if you were to use the name user_ settings for a protocol buffer (https://developers.google.com/protocol-buffers) message, it would be very easy to think that this is actually a field name and not a message. As a result, this is likely to cause confusion to anyone using the API. Speaking of types, let’s take a brief moment to look at reserved words.
在大多数 API 定义语言,都会有一种方法来指定存储在特定属性中的数据类型。例如,我们可能会说firstName: string在 TypeScript 中表示调用的字段firstName包含原始字符串值。这也意味着术语字符串有一些特殊的含义,即使在代码中的不同位置使用也是如此。因此,在 API 中使用受限关键字作为名称可能很危险,应尽可能避免。
In most API definition languages, there will be a way to specify the type of the data being stored in a particular attribute. For example, we might say firstName: string to express in TypeScript that the field called firstName contains a primitive string value. This also implies that term string has some special meaning, even if used in a different position in code. As a result, it can be dangerous to use restricted keywords as names in your API and should be avoided whenever possible.
如果这看起来很困难,花一些时间思考字段或消息真正代表什么而不是最简单的选择是什么是值得的。例如,您可能想尝试使用更具体的术语,例如“发件人”和“收件人”(如果 API 是关于消息的) 或者可能是“付款人”和“收款人”(如果 API 是关于付款的)。
If this seems difficult, it can be worthwhile to spend some time thinking about what a field or message truly represents and not what the easiest option is. For example, rather than “to” and “from” (from being those special reserved keywords in languages like Python), you might want to try using more specific terminology such as “sender” and “recipient” (if the API is about messages) or maybe “payer” and “payee” (if the API is about payments).
考虑 API 的目标受众也很重要。例如,如果 API 只会在 JavaScript 中使用(也许它打算专门在网络浏览器中使用),那么其他语言(例如 Python 或 Ruby)中的关键字可能不值得担心。也就是说,如果工作量不大,最好避免使用其他语言的关键字。毕竟,您永远不知道您的 API 何时可能最终被其中一种语言使用。
It’s also important to consider the target audience of your API. For example, if the API will only ever be used in JavaScript (perhaps it’s intended to be used exclusively in a web browser), then keywords in other languages (e.g., Python or Ruby) may not be worth worrying about. That said, if it’s not much work, it’s a good idea to avoid keywords in other languages. After all, you never know when your API might end up being used by one of these languages.
现在我们已经了解了其中的一些技术方面,让我们更上一层楼,谈谈我们的 API 存在和运行的环境如何影响名字我们选择。
Now that we’ve gone through some of these technical aspects, let’s jump up a level and talk about how the context in which our API lives and operates might affect the names we choose.
尽管名称本身有时可以传达有用的所有必要信息,但更多时候我们依赖名称使用的上下文来辨别其含义和预期用途。例如,当我们在 API 中使用术语book时,我们可能指的是存在于 Library API 中的资源;但是,我们也可能指的是要在航班预订 API 中执行的操作。可以想象,相同的词和术语可能表示完全不同的事物,具体取决于使用它们的上下文。这意味着我们在为其选择名称时需要牢记 API 所处的环境。
While names on their own can sometimes convey all the information necessary to be useful, more often than not we rely on the context in which a name is used to discern its meaning and intended use. For example, when we use the term book in an API, we might be referring to a resource that lives in a Library API; however, we might also be referring to an action to be taken in a Flight Reservation API. As you can imagine, the same words and terminology can mean completely different things depending on the context in which they’re used. What this means is that we need to keep the context in which our API lives in mind when choosing names for it.
重要的是要记住这是双向的。一方面,上下文可以赋予名称额外的价值,否则可能会缺乏特定的含义。另一方面,当我们使用具有非常特定含义但在给定上下文中不太有意义的名称时,上下文会使我们误入歧途。例如,如果没有附近的任何上下文,名称“record”可能不是很有用,但在录音 API 的上下文中,该术语吸收了 API 的一般上下文赋予的额外含义。
It’s important to remember that this goes both ways. On the one hand, context can impart additional value to a name that might otherwise lack specific meaning. On the other hand, context can lead us astray when we use names that have a very specific meaning but don’t quite make sense in the given context. For example, the name “record” might not be very useful without any context nearby, but in the context of an audio recording API, this term absorbs the extra meaning imparted from the API’s general context.
简而言之,虽然没有关于如何在给定上下文中命名事物的严格规则,但要记住的重要一点是,我们在 API 中选择的所有名称都与该 API 提供的上下文有着千丝万缕的联系。因此,在选择名称时,我们应该认识到该上下文及其可能赋予的含义(无论好坏)。
In short, while there are no strict rules about how to name things in a given context, the important thing to remember is that all the names we choose in an API are inextricably linked to the context provided by that API. As a result, we should be cognizant of that context and the meaning it might impart (for better or worse) when choosing names.
让我们稍微改变一下方向,谈谈数据类型和单位,特别是它们应该如何包含在我们的名称中选择。
Let’s change direction a bit and talk about data types and units, specifically how they should be involved in the names we choose.
尽管许多字段名称是没有单位的描述性的(例如,firstName: string),其他字段名称没有单位可能会非常混乱。例如,想象一个名为“大小”的字段。根据上下文(参见第 3.4 节),该字段可能具有完全不同的含义,但也可能具有完全不同的单位。我们可以看到相同的字段 ( size) 具有完全不同的含义和单位,在许多情况下,含义和单位令人困惑。
While many field names are descriptive without units (e.g., firstName: string), others can be extraordinarily confusing without units. For example, imagine a field called “size.” Depending on the context (see section 3.4), this field could have entirely different meanings but also entirely different units. We can see the same field (size) that would have entirely different and, in many cases, confusing meaning and units.
Listing 3.2 An audio clip and image using size fields
interface AudioClip { content: string; ❶ size: number; ❷ } interface Image { content: string; ❶ size: number; ❷ }
❶ This might contain Base64-encoded binary audio content.
❷该字段的单位比较混乱。它是以字节为单位的大小吗?或者音频的持续时间(以秒为单位)?或者图像的尺寸?或者是其他东西?
❷ The units of this field are confusing. Is it the size in bytes? Or the duration in seconds of the audio? Or dimensions of the image? Or something else?
在此示例中,大小字段可能表示多种含义,但这些不同的含义也会导致非常不同的单位(例如,字节、秒、像素等)。幸运的是,这种关系是双向的,这意味着如果单位出现在某个地方,意义就会变得更加清晰。换一种说法,sizeBytes和sizeMegapixels比 . 更清晰和明显size。
In this example, the size field could mean multiple things, but those different meanings also would lead to very different units (e.g., bytes, seconds, pixels, etc.). Luckily this relationship goes both ways, meaning that if the units were present somewhere the meaning would become more clear. In other words, sizeBytes and sizeMegapixels are much more clear and obvious than just size.
Listing 3.3 An audio clip and image using clearer size fields with units
interface AudioClip { content: string; sizeBytes: number; ❶ } interface Image { content: string; sizeMegapixels: number; ❶ }
❶ Now the meaning of these size fields is much more clear because the units are provided.
这是否意味着我们应该始终在所有场景中简单地包含任何给定字段的单位或格式?毕竟,这肯定会最大限度地减少所示情况下的任何混淆。例如,假设我们想要以像素资源存储图像的尺寸以及以字节为单位的大小。我们可能有两个字段称为sizeBytes和dimensionsPixels. 但尺寸实际上不止一个数字:我们需要长度和宽度。一种选择是使用字符串字段并以某种众所周知的格式设置维度。
Does this mean that we should always simply include the unit or format for any given field in all scenarios? After all, that would certainly minimize any confusion in cases like those shown. For example, imagine that we wanted to store the dimensions of the image in pixels resource along with the size in bytes. We might have two fields called sizeBytes and dimensionsPixels. But the dimensions are actually more than one number: we need both the length and the width. One option is to use a string field and have the dimensions in some well-known format.
Listing 3.4 An image storing the dimensions in pixels using a string field
interface Image { content: string; sizeBytes: number; // The dimensions (in pixels). E.g., "1024x768". ❶ dimensionsPixels: string; ❷ }
❶ The format of the field is expressed in a leading comment on the field itself.
❷ The units of the field are clear (pixels), but the primitive data type can be confusing.
虽然此选项在技术上是有效的并且肯定是明确的,但它显示出对始终使用原始数据类型的痴迷,即使它们可能没有意义。换句话说,就像有时名称中包含单位时名称会变得更加清晰和可用一样,其他时候使用更丰富的数据类型时名称也会变得更加清晰。Dimensions在这种情况下,我们可以使用一个接口,而不是使用组合两个数字的字符串类型具有长度和宽度数值,名称中包含单位(像素)。
While this option is technically valid and is certainly clear, it displays a bit of an obsession toward using primitive data types always, even when they might not make sense. In other words, just like sometimes names become more clear and usable when a unit is included in the name, other times a name can become more clear when using a richer data type. In this case, rather than using a string type that combines two numbers, we can use a Dimensions interface that has length and width numeric values, with the unit (pixels) included in the name.
Listing 3.5 An image with dimensions relying on a richer data type
interface Image { content: string; sizeBytes: number; dimensions: Dimensions; ❶ } interface Dimensions { lengthPixels: number; ❷ widthPixels: number; }
❶在这种情况下,维度字段名称不需要名称中的单位,因为更丰富的数据类型传达了含义。
❶ In this case, the dimensions field name doesn’t need a unit in the name as the richer data type conveys the meaning.
❷ The units of the field are clear (pixels) without any special string formatting.
在这种情况下,dimensions字段的含义很明显。此外,我们不必解压字段本身的一些特殊结构细节,因为Dimensions接口已经为我们做到了。让我们通过查看一些案例研究来结束命名这个话题应用程序接口。
In this case, the meaning of the dimensions field is clear and obvious. Further, we don’t have to unpack some special structural details of the field itself because the Dimensions interface has done this for us. Let’s wrap up this topic of naming by looking at some case studies of what can go wrong when we don’t take the proper caution when choosing names in an API.
这些关于如何选择好名字的指南以及在选择过程中值得考虑的各个方面都很好,但是可能值得看看几个使用不太正确的名字的真实示例。此外,我们可以看到这些命名选择的最终结果以及它们可能导致的潜在问题。让我们从一个命名问题开始,其中遗漏了一个微妙但重要的部分。
These guidelines about how to choose good names and the various aspects worth considering during that choosing process are all well and good, but it might be worthwhile to look at a couple of real-world examples using names that aren’t quite right. Further, we can see the end consequences of these naming choices and the potential issues they might cause. Let’s start by looking at a naming issue where a subtle but important piece is left out.
如果您走进 Krispy Kreme 甜甜圈店并要 10 个甜甜圈,您会期望得到 10 个甜甜圈,对吗?如果你只有 8 个甜甜圈,你会感到惊讶吗?也许如果你有 8 个甜甜圈,你会认为商店里的甜甜圈肯定已经卖光了。你马上得到 8 个甜甜圈,然后又要 2 个甜甜圈才能得到你想要的 10 个甜甜圈,这显然是不对的。
If you were to walk into a Krispy Kreme donut shop and ask for 10 donuts, you’d expect 10 donuts, right? And you’d be surprised if you only got 8 donuts? Maybe if you got 8 donuts you’d assume that the store must be completely out of donuts. It certainly wouldn’t seem right that you’d get 8 donuts right away, then have to ask for 2 more donuts to get your desired 10.
相反,如果您只能请求最多 N 个甜甜圈,该怎么办?换句话说,你只能问收银员“Can I have up to 10 donuts?” 您会得到任意数量的甜甜圈,但绝不会超过 10 个。(请记住,这可能会导致您得到零个甜甜圈!)第一个甜甜圈店示例中的奇怪行为突然变得有道理了。它仍然不方便(我还没有看到有这种订购系统的甜甜圈店),但至少它并不令人困惑和惊讶。
What if, instead, you only had a way to ask for a maximum of N donuts. In other words, you could only ask the cashier “Can I have up to 10 donuts?” You’d get back any number of donuts, but never more than 10. (And keep in mind that this might result in you getting zero donuts!) Suddenly the weird behavior in the first donut shop example makes sense. It’s still inconvenient (I’ve not yet seen a donut shop with this kind of ordering system), but at least it’s not baffling and surprising.
在第 21 章中,我们将了解一种设计模式,该模式演示了如何在列表标准方法操作期间以安全、清晰的方式对大量资源进行分页,并且可以很好地扩展到大量资源。事实证明,这种只要求最大值(而不是确切数量)的独特能力正是分页模式的工作原理(使用maxPageSize字段).
In chapter 21, we’ll learn about a design pattern that demonstrates how to page through a bunch of resources during a list standard method operation in a way that’s safe, clear, and scales nicely to lots and lots of resources. And it turns out that this exclusive ability to ask only for the maximum (and not an exact amount) is exactly how the pagination pattern works (using a maxPageSize field).
Google 的人们(由于历史原因)遵循所描述的分页模式,除了一个重要的区别:不是指定 amaxPageSize说“给我最多 N 项”,请求指定 apageSize. 缺少这三个字符会导致非常大的混乱,就像点甜甜圈的人一样:他们认为他们要的是一个确切的数字,但实际上他们只能要求一个最大数量。
The folks over at Google (for historical reasons) follow the pagination pattern as described except for one important difference: instead of specifying a maxPageSize to say “give me a maximum of N items,” requests specify a pageSize. These three missing characters lead to an extraordinarily large amount of confusion, just like the person ordering donuts: they think they’re asking for an exact number, but they actually are only able to ask for a maximum number.
最常见的情况是,有人要了 10 件商品,得到了 8 件,然后认为肯定没有更多的商品了(就像我们假设甜甜圈店的甜甜圈卖光了一样)。事实上,情况并非如此:仅仅因为我们退回了 8 个并不意味着这家店的甜甜圈已经卖光了;这只是意味着他们必须去后面寻找更多。这最终会导致 API 用户错过很多项目,因为他们在列表实际结束之前停止对结果进行分页。
The most common scenario is when someone asks for 10 items, gets back 8, and thinks that there must be no more items (just like we might assume the donut shop is out of donuts). In fact, this isn’t the case: just because we got 8 back doesn’t mean the shop is out of donuts; it just means that they have to go find more in the back. This ultimately results in API users to miss out on lots of items because they stop paging through the results before the actual end of the list.
虽然这可能令人沮丧并导致一些不便,但让我们看一下混淆字段单位所犯的更严重的错误。
While this might be frustrating and lead to some inconvenience, let’s look at a more serious mistake made by mixing up units for a field.
后退1999 年,美国宇航局计划将火星气候轨道器调入离地表约 140 英里的轨道。他们进行了大量计算,以准确计算出使轨道飞行器进入正确位置并执行机动所需的脉冲力。不幸的是,此后不久,该团队注意到轨道飞行器并不完全在预期的位置。它不是在离地表 140 英里的地方,而是远低于此。事实上,后来的计算似乎表明轨道飞行器应该在距离地面 35 英里的范围内。可悲的是,轨道飞行器可以生存的最低高度是 50 英里。如您所料,低于该楼层意味着轨道飞行器很可能在火星大气层中被摧毁。
Back in 1999, NASA planned to maneuver the Mars Climate Orbiter into an orbit about 140 miles above the surface. They did a bunch of calculations to figure out exactly what impulse forces to apply in order to get the orbiter into the right position and then executed the maneuver. Unfortunately, soon after that the team noticed that the orbiter was not quite where it was supposed to be. Instead of being at 140 miles above the surface, it was far lower than that. In fact, calculations made later seemed to show that the orbiter would’ve been within 35 miles of the surface. Sadly, the minimum altitude the orbiter could survive was 50 miles. As you’d expect, going below that floor means that the orbiter was likely destroyed in Mars’s atmosphere.
在随后的调查中,发现洛克希德马丁团队以美国标准单位(特别是 lbf-s 或磅力秒)进行输出,而 NASA 团队以 SI 单位(特别是 Ns 或牛顿秒)工作。快速计算表明,1 lbf-s 相当于 4.45 Ns,这最终导致轨道飞行器获得所需冲量的四倍多,最终使其低于最低高度。
In the investigation that followed, it was discovered that the Lockheed Martin team produced output in US standard units (specifically, lbf-s or pound-force seconds) whereas the NASA teams worked in SI units (specifically, N-s or Newton seconds). A quick calculation shows that 1 lbf-s is equivalent to 4.45 N-s, which ultimately resulted in the orbiter getting more than four times the amount of impulse force needed, which ultimately sent it below its minimum altitude.
清单 3.6 用于计算 MCO 的 API 的(非常简化的)示例
Listing 3.6 A (very simplified) example of the API for calculations on the MCO
abstract class MarsClimateOrbiter { CalculateImpulse(CalculateImpulseRequest): CalculateImpulseResponse; ❶ CalculateManeuver(CalculateManeuverRequest): CalculateManeuverResponse; ❶ } interface CalculateImpulseResponse { impulse: number; ❷ } interface CalculateManeuverRequest { impulse: number; ❷ }
❶为简洁起见,省略了 CalculateImpulseRequest 和 CalculateManeuverResponse 接口。
❶ The CalculateImpulseRequest and CalculateManeuverResponse interfaces are omitted for brevity.
❷这里我们计算的是冲量,但是没有单位!这意味着我们可以将之前的输出作为下一个输入。
❷ Here we have the impulse calculated, but there are no units! This implies we can feed the previous output as the next input.
另一方面,如果集成点在字段名称中包含单位,则错误会更加明显。
If, on the other hand, the integration point had included the units in the names of the fields, the error would’ve been far more obvious.
Listing 3.7 Alterations to the example interfaces to include units
interface CalculateImpulseResponse { impulsePoundForceSeconds: number; ❶ } interface CalculateManeuverRequest { impulseNewtonSeconds: number; ❶ }
❶在这里很明显,由于单位不同,您不能只获取一个 API 方法的输出并将其提供给下一个方法。
❶ Here it becomes obvious that you can’t just take the output of one API method and feed it into the next method due to the different units.
显然,火星气候轨道飞行器是一个比这里描述的要复杂得多的软件和机械,并且不太可能仅仅通过使用更具描述性的名称。也就是说,它很好地说明了为什么描述性名称很有价值,并且可以帮助突出假设中的差异,尤其是在协调两者时不同的团队。
Obviously the Mars Climate Orbiter was a far more complicated piece of software and machinery than portrayed here, and it’s unlikely that this exact scenario (https://en.wikipedia.org/wiki/Mars_Climate_Orbiter#Cause_of_failure) could have been avoided simply by using more descriptive names. That said, it’s a good illustration of why descriptive names are valuable and can help highlight differences in assumptions, particularly when coordinating between different teams.
想象您需要创建一个 API 来管理重复发生的计划(“此事件每月发生一次”)。一位高级工程师认为,为所有用例存储事件之间的秒数就足够了。另一位工程师认为 API 应该为不同的时间单位(例如,秒、分钟、小时、天、周、月、年)提供不同的字段。哪种设计涵盖了预期功能的正确含义并且是更好的选择?
Imagine you need to create an API for managing recurring schedules (“This event happens once per month”). A senior engineer argues that storing a value for seconds between events is sufficient for all the use cases. Another engineer thinks that the API should provide different fields for various time units (e.g., seconds, minutes, hours, days, weeks, months, years). Which design covers the correct meanings of the intended functionality and is the better choice?
在您的公司中,存储系统使用千兆字节作为度量单位(10 9字节)。例如,在创建共享文件夹时,您可以通过设置将大小设置为 10 GB sizeGB = 10。一个新的 API 正在启动,其中网络吞吐量以 Gibibits(2 30位)为单位进行测量,并希望根据 Gibibits 设置带宽限制(例如,bandwidthLimitGib = 1)。这是不是太微妙了,可能会让用户感到困惑?为什么或为什么不是?
In your company, storage systems use gigabytes as the unit of measurement (109 bytes). For example, when creating a shared folder, you can set the size to 10 gigabytes by setting sizeGB = 10. A new API is launching where networking throughput is measured in Gibibits (230 bits) and wants to set bandwidth limits in terms of Gibibits (e.g., bandwidthLimitGib = 1). Is this too subtle a difference and potentially confusing for users? Why or why not?
Good names, like good APIs, are simple, expressive, and predictable.
When it comes to language, grammar, and syntax (and other arbitrary choices), often the right answer is to pick something and stick to it.
Prepositions in names are often API smells that hint at some larger underlying design problem worth fixing.
Remember that the context in which a name is used both imparts information and can be potentially misleading. Be aware of the context in place when choosing a name.
Include the units for primitives and rely on richer data types to help convey information not present in a name.
正如我们在第 1 章中了解到的那样,将我们的注意力从操作转移到资源上可以让我们通过利用简单的模式更轻松、更快速地熟悉 API。例如,REST 提供了一组标准动词,我们可以将其应用于一堆资源,这意味着对于我们了解的每一种资源,我们还会选择可以对该资源执行的五种不同操作(创建、获取、列出、删除, 并更新)。
As we learned in chapter 1, shifting our focus away from actions and toward resources allows us to more easily and more quickly build our familiarity of an API by leveraging simple patterns. For example, REST provides a set of standard verbs we can apply to a bunch of resources, meaning that for every resource we learn about, we also pick up five different actions that can be performed on that resource (create, get, list, delete, and update).
虽然这很有价值,但它意味着仔细考虑我们定义为 API 一部分的资源变得更加重要。选择正确资源的一个关键部分是了解它们在未来将如何组合在一起。在本章中,我们将探讨如何在 API 中布置各种资源、可用的选项以及选择正确的资源关联方式的一般准则。此外,在考虑如何在 API 中布置一组资源时,我们将研究一些反模式(不要做的事情)。让我们从头开始,看看我们所说的资源布局具体指的是什么。
While this is valuable, it means that it becomes far more important to think carefully about the resources we define as part of an API. A key part of choosing the right resources is understanding how they’ll fit together in the future. In this chapter, we’ll explore how we might lay out the various resources in an API, the options available, and general guidelines for choosing the right way to associate resources with one another. Additionally, we’ll look at a couple of anti-patterns (things not to do) when considering how to lay out a set of resources in an API. Let’s start at the beginning by looking at what we mean specifically by resource layout.
什么时候我们谈论资源布局,通常是指 API 中资源(或“事物”)的排列、定义这些资源的字段,以及这些资源如何通过这些字段相互关联。换句话说,资源布局是针对特定 API 设计的实体(资源)关系模型。例如,如果我们要为聊天室构建一个 API,资源布局指的是我们做出的可能导致ChatRoom资源的选择连同User资源这可能以某种方式与房间相关联。这种资源User和ChatRoom资源相互关联的方式是我们感兴趣的。如果您曾经设计过包含各种表的关系数据库,这应该会很熟悉:您设计的数据库模式在本质上通常与API 的表示方式。
When we talk about resource layout, we generally mean the arrangement of resources (or “things”) in our API, the fields that define those resources, and how those resources relate to one another through those fields. In other words, resource layout is the entity (resource) relationship model for a particular design of an API. For example, if we were to build an API for a chat room, the resource layout refers to the choices we make that might result in a ChatRoom resource along with a User resource that might associate somehow with a room. This manner, in which User and ChatRoom resources associate with one another, is what we’re interested in. If you’ve ever designed a relational database with various tables, this should feel familiar: the database schema you design is often very similar in nature to how the API is represented.
虽然说关系是最重要的可能很诱人,但实际上它比这更复杂。虽然关系本身是唯一直接影响我们最终得到的资源布局的因素,但还有许多其他因素会间接影响布局。一个明显的例子是资源选择本身:如果我们选择不拥有User资源,而是坚持按名称 ( members: string[]) 列出一个简单的用户列表,那么就没有其他资源可供布局,问题就完全避免了。
While it might be tempting to say that the relationship is all that matters, it’s actually a bit more involved than this. While the relationship itself is the only thing with a direct influence in the resource layout we end up with, there are many other factors that influence the layout indirectly. One obvious example is the resource choices themselves: if we choose to not have a User resource and instead stick to a simple list of users by name (members: string[]), then there’s no other resource to lay out and the question is avoided entirely.
顾名思义,当我们将 API 的资源布局可视化为通过线相互连接的框时,它可能最容易理解。例如,图 4.1 显示了我们如何看待我们的聊天室示例,其中包含大量用户和大量相互关联的聊天室。
As the name hints, the resource layout of an API is probably easiest to understand when we look at it visually as boxes connected to one another by lines. For example, figure 4.1 shows how we might think of our chat room example with lots of users and lots of chat rooms all interconnected to one another.
Figure 4.1 A chat room contains lots of user members.
如果我们正在构建一个在线购物 API(例如,像亚马逊这样的东西),我们可能会存储User资源及其各种地址(用于运送在线购买的商品)和支付方式。此外,支付方式本身可以参考支付方式的账单地址的地址。这种布局如图 4.2 所示。
And if we were building an online shopping API (e.g., something like Amazon), we might store User resources along with their various addresses (for shipping items bought online) and payment methods. Further, payment methods could themselves have a reference to an address for the billing address of the payment method. This layout is shown in figure 4.2.
Figure 4.2 Users, payment methods, and addresses all have different relationships to one another.
简而言之,重要的是要记住,资源布局是一个广泛的概念,它包含我们为 API 选择的资源,最重要的是,这些资源如何相互作用和相互关联。在下一节中,我们将简要介绍不同类型的关系以及每种类型可能提供的交互(和限制)。
In short, it’s important to remember that resource layout is a broad concept that encompasses the resources we choose for an API and, most importantly, how those resources interact and relate to one another. In the next section, we’ll take a brief tour of the different types of relationships and the interactions (and limitations) each type might provide.
什么时候在考虑资源布局时,我们必须考虑资源相互关联的各种方式。重要的是要考虑到我们将讨论的所有关系本质上都是双向的,这意味着即使关系看起来是单向的(例如,一条消息指向作为作者的单个用户),反向关系仍然存在,即使它是未明确定义(例如,用户能够创作多个不同的消息)。让我们通过查看最常见的关系形式来深入了解:参考。
When thinking about the resource layout, we have to consider the variety of ways in which the resources can relate to one another. It’s important to consider that all relationships we’ll talk about are bidirectional in nature, meaning that even if the relationship seems one-sided (e.g., a message points to a single user as the author), the reverse relationship still exists even if it’s not explicitly defined (e.g., a user is capable of authoring multiple different messages). Let’s dive right in by looking at the most common form of relationship: references.
这两种资源相互关联的最简单方法是通过简单引用。通过这个,我们的意思是一个资源引用或指向另一个资源。例如,在聊天室 API 中,我们可能有Message资源构成聊天室的内容。在这种情况下,每条消息显然都由单个用户编写并存储在消息的author字段中。这将导致消息和用户之间的简单引用关系:消息有一个author指向特定用户的字段,如图 4.3 所示。
The simplest way for two resources to relate to one another is by a simple reference. By this, we mean that one resource refers to or points at another resource. For example, in a chat room API we might have Message resources that make up the content of the chat room. In this scenario, each message would obviously be written by a single user and stored in a message’s author field. This would lead to a simple reference relationship between a message and a user: a message has an author field that points at a specific user, shown in figure 4.3.
Figure 4.3 A message resource contains a reference to a specific user who authored the message.
这种引用关系有时被称为外键关系,因为每个Message资源都指向一个User资源作为作者。不过,如前所述,User资源显然能够关联许多不同的消息。因此,这也可以被认为是多对一的关系一个用户可能会写很多消息,但一条消息总是有一个用户作为作者。
This reference relationship is sometimes referred to as a foreign key relationship because each Message resource would point to exactly one User resource as an author. As noted earlier though, a User resource is obviously capable of having many different messages associated with it. As a result, this can also be considered a many-to-one relationship where a user might write many messages but a message always has one user as the author.
像作为引用的更高级版本,多对多关系代表一种场景,其中资源以这样一种方式连接在一起,即每个资源都指向另一个资源的多个实例。例如,如果我们有一个ChatRoom用于群组对话的资源,这显然会包含许多个人用户作为成员。然而,每个用户也可以成为多个不同聊天室的成员。在这种情况下,ChatRoom资源与用户之间存在多对多关系。AChatRoom指向大量User资源,因为房间的成员和用户能够指向他们可能是其中成员的多个聊天室。
Like a fancier version of references, a many-to-many relationship represents a scenario where resources are joined together in such a way that each resource points at multiple instances of the other. For example, if we have a ChatRoom resource for a group conversation, this will obviously contain lots of individual users as members. However, each user is also able to be a member of multiple different chat rooms. In this scenario, ChatRoom resources have a many-to-many relationship with users. A ChatRoom points at lots of User resources as the members of the room and a user are able to point at multiple chat rooms of which they might be members.
这种关系如何工作的机制留待未来探索(我们将在第 3 部分中看到),但这些多对多关系在 API 中非常常见,并且有几种不同的方式来表示它们,每个都有自己的选择好处和缺点。
The mechanics of how this relationship works is left for future exploration (we see these in part 3), but these many-to-many relationships are very common in APIs and have several different options for how they can be represented, each with their own benefits and drawbacks.
在至此,我们可以讨论一下听起来很奇怪但实际上只是引用的另一种特殊版本:自引用。顾名思义,在这种关系中,资源指向完全相同类型的另一个资源,因此 self 指的是类型而不是资源本身。事实上,这就像一个正常的引用关系;然而,我们必须将其称为不同的东西,因为它是典型的视觉表示,如图 4.4 所示,其中箭头指向资源本身。
At this point, we can discuss what might sound strange but is in fact just another special version of a reference: self-references. As the name hints, in this relationship a resource points to another resource of the exact same type, so the self refers to the type rather than the resource itself. In fact, this is exactly like a normal reference relationship; however, we have to call this out as something different because of its typical visual representation, shown in figure 4.4, where an arrow points back at the resource itself.
Figure 4.4 An employee resource points at other employee resources as managers and assistants.
您可能想知道为什么一个资源会指向完全相同类型的另一个资源。这是一个合理的问题,但这种类型的关系实际上比您预期的要频繁得多。自引用最常出现在分层关系中,其中资源是树中的一个节点,或者出现在网络样式的 API 中,其中数据可以表示为有向图(如社交网络)。
You may be wondering why a resource would point at another resource of the exact same type. That’s a reasonable question, but this type of relationship actually shows up far more often than you might expect. Self-references are most frequently seen in hierarchical relationships where the resource is a node in a tree or in network-style APIs where the data can be represented as a directed graph (like a social network).
例如,想象一个用于存储公司目录的 API。在这种情况下,员工相互指向以跟踪谁向谁报告(例如,员工 1 向员工 2 报告)。此 API 可能还具有针对特殊关系的进一步自引用(例如,员工 1 的助理是员工 2)。在每一种情况下,我们都可以使用以下方法对资源布局进行建模自我参考。
For example, imagine an API for storing a company directory. In that scenario, employees point to one another to keep track of who reports to whom (e.g., employee 1 reports to employee 2). This API might also have further self-references for special relationships (e.g., employee 1’s assistant is employee 2). In each of these cases, we can model the resource layout with a self-reference.
最后,我们需要讨论一种非常特殊的关系类型,它是标准引用关系的另一种形式:层次结构。层级关系有点像一种资源具有指向另一种资源的指针,但该指针通常指向上方并且意味着不止一种资源指向另一种资源。与典型的引用关系不同,层次结构还倾向于反映资源之间的包含或所有权,最好使用计算机上的文件夹术语来解释。您计算机上的文件夹(或 Linux 用户的目录)包含一堆文件,从这个意义上说,这些文件归文件夹所有。文件夹还可以包含其他文件夹,然后循环重复,有时会无限期地重复。
Finally, we need to discuss a very special type of relationship, which is another take on the standard reference relationship: hierarchies. Hierarchical relationships are sort of like one resource having a pointer to another, but that pointer generally aims upward and implies more than just one resource pointing at another. Unlike typical reference relationships, hierarchies also tend to reflect containment or ownership between resources, perhaps best explained using folder terminology on your computer. The folders (or directories for Linux users) on your computer contain a bunch of files and in that sense these files are owned by the folder. Folders can also contain other folders, and the cycle then repeats, sometimes indefinitely.
这可能看起来无害,但这种特殊关系暗示了一些重要的属性和行为。例如,当您删除计算机上的文件夹时,通常其中包含的所有文件(和其他文件夹)也会被删除。或者,如果您有权访问特定文件夹,这通常意味着可以访问其中的文件(和其他文件夹)。从以这种方式运行的资源中可以预期到这些相同的行为。
This might seem innocuous, but this special relationship implies some important attributes and behaviors. For example, when you delete a folder on your computer, generally all the files (and other folders) contained inside are likewise deleted. Or, if you are given access to a specific folder, that often implies access to the files (and other folders) inside. These same behaviors have come to be expected from resources that behave in this way.
在我们走得太远之前,让我们看看层次关系是什么样的。实际上,我们在前面的章节中一直在使用层次关系的例子,所以它们看起来并不奇怪。例如,我们讨论过ChatRoom由一堆资源组成的Message资源。在这种情况下,存在ChatRooms 包含或拥有 Messages的隐含层次结构,如图 4.5 所示。
Before we get too far along, let’s look at what a hierarchical relationship looks like. We’ve actually been using examples of hierarchical relationships in previous chapters, so they shouldn’t seem surprising. For example, we’ve talked about ChatRoom resources that are made up of a bunch of Message resources. In this case, there is an implied hierarchy of ChatRooms containing or owning Messages, shown in figure 4.5.
图 4.5 ChatRoom 资源通过层级关系充当 Message 资源的所有者。
Figure 4.5 ChatRoom resources act as the owner of Message resources through hierarchical relationships.
正如您可以想象的那样,这通常意味着拥有对ChatRoom资源的访问权也将授予对Message构成聊天室内容主体的资源的访问权。此外,删除ChatRoom资源时,通常假定此操作会Message根据父子层次关系向下级联到资源。在某些情况下,这种级联效应是一个巨大的好处(例如,能够删除我们计算机上的整个文件夹而无需先删除其中的每个单独文件是很好的)。在其他情况下,级联行为可能会带来很大问题(例如,我们可能认为我们正在授予对该文件夹的访问权限,而实际上我们正在授予对该文件夹内所有文件(包括子文件夹)的访问权限)。
As you can imagine, it’s generally implied that having access to a ChatRoom resource would also grant access to the Message resources that constitute the body of content in the chat room. Additionally, when deleting the ChatRoom resource, it’s generally assumed that this action cascades down to the Message resources based on the parent-child hierarchical relationship. In some cases, this cascading effect is a huge benefit (e.g., it’s nice to be able to delete an entire folder on our computer without first deleting every individual file inside). In other cases, cascading behavior can be quite problematic (e.g., we might think we’re granting access to a folder when we’re actually granting access to all files inside that folder, including subfolders).
一般来说,层次关系很复杂,会导致很多棘手的问题。例如,资源可以改变父母吗?换句话说,我们能否将Message资源从一种ChatRoom资源转移到另一种资源?(通常,这是个坏主意。)我们将更多地探讨层次结构及其优缺点在第 4.2 节。
In general, hierarchical relationships are complex and lead to lots of tricky questions. For example, can resources change parents? Put differently, can we move a Message resource from one ChatRoom resource to another? (Generally, that’s a bad idea.) We’ll explore more about hierarchies and both their drawbacks and benefits in section 4.2.
始终在本章中,您可能已经在连接不同资源的行的末端看到了一些有趣的符号。虽然我们没有时间全面深入研究 UML(统一建模语言; https://en.wikipedia.org/wiki/Unified_Modeling_Language),我们至少可以看看其中的一些箭头并解释它们是如何工作的。
Throughout this chapter, you may have seen some interesting symbols at the ends of the lines connecting the different resources. While we don’t have time to go into a full deep dive of UML (Unified Modeling Language; https://en.wikipedia.org/wiki/ Unified_Modeling_Language), we can at least look at some of these arrows and explain how they work.
简而言之,我们可以让箭头末端传达有关关系的重要信息,而不是从一个资源指向另一个资源的任意箭头。更具体地说,每个箭头端都可以告诉我们另一端可能有多少资源。例如,图 4.6 显示一所学校有很多学生,但每个学生只就读一所学校。此外,每个学生参加许多班级,每个班级包含许多学生。
In short, rather than an arbitrary arrow pointing from one resource to another, we can have the arrow ends convey important information about the relationship. More specifically, each arrow end can tell us how many resources might be on the other end. For example, figure 4.6 shows that a school has many students, but each student attends only one school. Additionally, each student attends many classes and each class contains many students.
Figure 4.6 Example entity relationship diagram showing schools, students, and classes
虽然这两个符号肯定是最常见的,但您可能会不时看到其他符号。这些是为了涵盖任何关联资源都是可选的情况。例如,从技术上讲,一个班级可能由零名学生组成(反之亦然)。因此,从技术上讲,该图可能看起来更像图 4.7。
While these two symbols are certainly the most common, there are others you may see from time to time. These are to cover the cases where any associated resources are optional. For example, technically a class may be made up of zero students (and vice versa). As a result, technically, the diagram may look more like figure 4.7.
图 4.7 更新后的图表显示学生可能无法注册课程(并且课程可能没有任何学生注册)
Figure 4.7 Updated diagram showing students may not enroll in classes (and classes may not have any students enrolled)
有时很难掌握阅读此类符号的诀窍,因此它可能有助于阐明应阅读每个连接器的方向。阅读这些图表的最佳方式是从一个资源开始,跟随线条,然后查看线条末尾的连接器,然后在另一个资源处结束。重要的是要记住,您正在跳过接触您开始使用的资源的连接器符号。为了更清楚地说明这一点,图 4.8 将School和Student资源之间的连接分成了两个单独的图,并在该行的旁边写上了应该读取连接的方向。
Sometimes it can be tough to get the hang of reading this type of notation, so it might help to clarify the direction in which each connector should be read. The best way to read these diagrams is to start at one resource, following the line, then look at the connector at the end of the line, then finish at the other resource. It’s important to remember that you’re skipping the connector symbol that’s touching the resource you’re starting with. To make that more clear, figure 4.8 shows the connection between the School and Student resources broken up into two separate diagrams, with the direction in which the connection should be read written next to the line.
School图 4.8 以和Student资源为例如何解读实体关系
Figure 4.8 How to read entity relationships using School and Student resources as examples
正如我们所看到的,一所学校有很多学生,学生有一个学校。为简单起见,我们将这两者合并为一个图表和连接两者的一条线,正如我们在前面的示例中看到的那样。
As we can see, a school has many students and students have one school. For simplicity’s sake we combine these two into a single diagram and single line connecting the two, as we saw in our previous examples.
现在我们已经很好地掌握了如何阅读这些图表,让我们开始更重要的工作,即通过选择正确的关系来弄清楚如何为我们的 API 建模之间资源。
Now that we have a good grasp on how to read these diagrams, let’s get to the more important work of figuring out how to model our APIs by choosing the right relationships between resources.
作为我们在 4.1 节中了解到,选择正确的关系类型通常首先取决于我们选择的资源,因此我们将在本节中将这两者视为相关联。让我们从一个重要的问题开始:我们到底需要一段关系吗?
As we learned in section 4.1, making the choice about the right type of relationship often depends on the resources we choose in the first place, so we’ll treat these two as linked for this section. Let’s start by looking at an important question: do we need a relationship at all?
什么时候构建 API,在我们选择了对我们重要的事物或资源列表后,下一步是决定这些资源如何相互关联。有点像规范化数据库模式,通常很想连接所有可能需要连接的东西,以构建一个丰富的互连资源网络,我们可以轻松地通过各种指针和引用进行导航。虽然这肯定会导致 API 非常丰富且具有描述性,但随着 API 接收越来越多的流量并存储越来越多的数据,它也可能变得站不住脚并导致严重的性能下降。
When building an API, after we’ve chosen the list of things or resources that matter to us, the next step is to decide how these resources relate to one another. Sort of like normalizing a database schema, it can often be tempting to connect everything that might ever need to be connected in order to build a rich web of interconnected resources that we can easily navigate through a variety of pointers and references. While this will certainly lead to a very rich and descriptive API, it can also become untenable and run into serious performance degradation as the API receives more and more traffic and stores more and more data.
为了理解我们的意思,让我们想象一个简单的 API,用户可以在其中相互关注(例如,Instagram 或 Twitter 之类的东西)。在这种情况下,用户有一个多对多的自引用,其中一个用户关注很多其他用户,每个用户可能有很多关注者,如图 4.9 所示。
To see what we mean, let’s imagine a simple API where users can follow one another (e.g., something like Instagram or Twitter). In this case, users have a many-to-many self-reference where a user follows lots of other users and each user might have lots of followers, as shown in figure 4.9.
Figure 4.9 A User resource with a self-reference to reflect users following one another
这可能看起来无害,但当您拥有数百万关注者的用户(以及关注数百万其他用户的用户)时,它可能会变得非常混乱,因为这些类型的关系会导致对一种资源的单一更改可能影响数百万其他相关资源的情况. 例如,如果某个名人删除了他们的 Instagram 帐户,则可能需要删除或更新数百万条记录(取决于底层数据库模式)。
This might seem innocuous, but it can get pretty messy when you have users with millions of followers (and users following millions of other users) because these types of relationships lead to scenarios where a single change to one resource can affect millions of other related resources. For example, if someone famous deletes their Instagram account, millions of records might need to be removed or updated (depending on the underlying database schema).
这并不是说您应该不惜一切代价避免任何关系。相反,重要的是尽早权衡任何给定关系的长期成本。换句话说,资源布局(和关系)并不总是免费的。就像在签署文书之前了解您的抵押贷款可能花费多少很重要一样,在设计过程中而不是在尾端识别给定 API 设计的真实成本同样重要。在维护关系真正重要的场景中(例如,前面的示例,其中存储关注者关系似乎非常重要),有一些方法可以减轻性能下降,但在任何情况下定义引用关系时保持明智仍然是一个好主意应用程序接口。
This isn’t to say that you should avoid any relationships at all costs. Instead, it’s important to weigh the long-term cost of any given relationship early on. In other words, the resource layout (and the relationships) is not always free of cost. And just like it’s important to know how much your mortgage might cost you before you sign the paperwork, it’s likewise important to recognize the true cost of a given API design during the design process rather than at the tail end. In scenarios where it’s truly critical to maintain a relationship (e.g., the previous example, where storing follower relationships seems pretty important), there are ways to mitigate the performance degradation, but it’s still a good idea to be judicious when defining reference relationships in any API.
换句话说,参考关系应该是有目的的并且是期望行为的基础。换句话说,这些关系绝不应该是偶然的、拥有的很好,或者你以后可能需要的东西。相反,任何引用关系都应该是 API 实现其主要目标的重要因素。
Put differently, reference relationships should be purposeful and fundamental to the desired behavior. In other words, these relationships should never be accidental, nice to have, or something you might need later on. Instead, any reference relationship should be something important for the API to accomplish its primary goal.
例如,Twitter、Facebook 和 Instagram 等服务是围绕用户之间相互关注的关系而构建的。用户之间的自我参照关系对于这些服务的目标来说是真正的基础——毕竟,如果没有这种关系,Instagram 将等同于一个简单的照片存储应用程序。将其与直接消息服务之类的东西进行比较;我们可以看到,尽管这种 API 确实以聊天的形式涉及用户之间的关系,但它们对应用程序来说肯定不是同样重要的。例如,聊天中涉及的两个用户之间的关系很重要,但潜在用户之间的所有关系的完整集合就不那么重要了联系人。
For example, services like Twitter, Facebook, and Instagram are built around relationships between users following one another. A self-reference relationship between users is truly fundamental to the goal of these services—after all, without this relationship Instagram would become equivalent to a simple photo storage app. Compare this to something like a direct messaging service; we can see that even though this kind of API certainly involves relationships between users in the form of chats, they’re certainly not critical to the application in the same way. For example, it’s important to have a relationship between the two users involved in the chat but not so important to have a full collection of all relationships between potential contacts.
假设某种关系对您的 API 的行为和功能至关重要,然后我们必须探索并回答一些重要问题。首先,我们需要探索在您的 API 中内联数据(即在资源中存储重复副本)或依赖引用(即仅保留指向官方数据的指针)是否有意义。让我们通过查看一个依赖于我们的聊天室示例的简单场景来做到这一点。
Assuming that some sort of relationship is critical to the behavior and functionality of your API, we then have to explore and answer some important questions. First, we need to explore whether it makes sense to in-line the data in your API (i.e., store a duplicate copy inside a resource), or rely on a reference (i.e., keep just a pointer to the official data). Let’s do this by looking at a simple scenario that relies on our chat room example.
想象一下,每个聊天室都必须有一个管理员用户。我们有两种选择:我们可以通过参考字段指向管理员用户(例如,adminUserId) 或者我们可以内联该用户的数据并将其表示为ChatRoom资源的一部分。要了解这是什么样子,请查看显示每个场景的图 4.10 和 4.11。
Imagine that each chat room must have one single administrator user. We have two options: we can either point at the administrator user via a reference field (e.g., adminUserId) or we can in-line the data for this user and represent it as a part of the ChatRoom resource. To see what this looks like, look at figures 4.10 and 4.11 that show each scenario.
Figure 4.10 A ChatRoom resource with a reference to the administrative User resource
在第一个图中(图4.10)我们可以看到指定管理员时ChatRoomresource指向的是resource。User另一方面,在第二张图(图 4.11)中,我们可以看到adminUser场实际上包含有关管理员的信息。这就引出了一个明显的问题:哪一个最好?事实证明,答案取决于您提出的问题或您正在执行的操作。
In the first diagram (figure 4.10) we can see that the ChatRoom resource points at the User resource when specifying the administrator. On the other hand, in the second diagram (figure 4.11) we can see that the adminUser field actually contains the information about the administrator. This leads to the obvious question: which of these is best? It turns out the answer depends on the questions you’re asking or the actions you’re performing.
Figure 4.11 A ChatRoom resource with an administrative user represented in-line
如果您要查看管理员的姓名,使用内联数据要容易得多。为了了解原因,让我们看看我们必须做什么才能为每个场景获取此信息哦。
If you’re trying to see the administrator’s name, it’s far easier to use the in-line data. To see why, let’s look at what we’d have to do to get this information for each scenario.
Listing 4.1 Retrieving the administrator for both reference and in-line examples
function getAdminUserNameInline(chatRoomId: string): string { let chatRoomInline = GetChatRoomInline(chatRoomId); ❶ return chatRoomInline.adminUser.name; } function getAdminUserNameReference(chatRoomId: string): string { let chatRoomRef = GetChatRoomReference(chatRoomId); ❶ let adminUser = GetAdminUser(chatRoomRef.adminUserId); ❷ return adminUser.name; }
❶在这两种情况下,我们都从检索 ChatRoom 资源开始。
❶ In both cases, we start by retrieving the ChatRoom resource.
❷在引用的情况下,如果我们需要有关管理员的更多信息,我们需要进行第二次查找。
❷ In the case of a reference, we have a second lookup to do if we want more information on the administrator.
这两个函数最明显的区别是需要网络响应的实际 API 调用的数量。首先,在数据在线返回的情况下,我们只需要一次 API 调用即可检索所有相关信息。在第二种情况下,我们必须先检索ChatRoom资源,然后才知道我们对哪个用户感兴趣。之后,我们必须检索User资源以找出名称。
The most obvious difference in these two functions is the number of actual API calls that require a network response. In the first, where data is returned in-line, we only need a single API call to retrieve all the relevant information. In the second, we have to first retrieve the ChatRoom resource and only then do we know which user we’re interested in. After that, we have to retrieve the User resource in order to find out the name.
这是否意味着内联我们的数据总是更好?不完全的。这是一个示例,我们需要双倍的 API 调用次数来获取我们感兴趣的信息。但是如果我们不经常对这些信息感兴趣怎么办?如果是这种情况,那么每次有人请求资源时我们都会发送大量字节ChatRoom,而这些字节只是被忽略了。换句话说,每当有人请求ChatRoom资源时,我们也会告诉他们有关该User房间管理员的所有资源。
Does this mean it’s always better to in-line our data? Not quite. This is one example where we need double the number of API calls to get the information we’re interested in. But what if we aren’t interested in that information very often? If that’s the case, then we’re sending down lots and lots of bytes every time someone asks for a ChatRoom resource, and those bytes are just being ignored. In other words, whenever someone asks for a ChatRoom resource, we also tell them all about the User resource that is the administrator for that room.
这可能看起来没什么大不了的,但如果每个用户也User在线存储他们所有的朋友(其他资源)呢?在这种情况下,将聊天室返回给管理员实际上可能会产生大量额外数据。此外,在提出所有这些额外的内联数据时,通常会涉及额外的计算工作(通常来自需要在后台连接数据的数据库查询)。它有可能以不可预测的方式快速增长。那么我们该怎么办?
This might not seem like a big deal, but what if each user also stores all of their friends (other User resources) in-line as well? In that case, returning the administrator with the chat room might actually result in a whole lot of extra data. Further, there’s often extra computational effort (typically coming from database queries that require joining data under the hood) involved when coming up with all of this extra in-line data. And it has the potential to grow quite fast and in unpredictable ways. So what do we do?
不幸的是,这将取决于您的 API 正在做什么,因此这是一个判断调用。一个好的经验法则是在不影响高级案例的可行性的情况下针对常见案例进行优化。这意味着我们需要考虑相关资源是什么、它现在有多大以及它可能会变得多大,并决定在响应中包含管理员信息的重要性。在这种情况下,典型的用户可能并不经常查找他们聊天室的管理员是谁,而是专注于向他们的朋友发送消息。因此,内联这些数据可能并不是那么重要。另一方面,User资源可能非常小,因此如果 API 经常使用这些数据,内联这些数据可能不是什么大问题。
Unfortunately this will depend on what your API is doing, so this is a judgment call. A good rule of thumb is to optimize for the common case without compromising the feasibility of the advanced case. That means we need to take into consideration what the related resource is, how large it is now, and how large it’s likely to get and decide how important it is to include the administrator’s information in the response. In this scenario, the typical user probably isn’t looking up who the administrator of their chat room is all that often and is instead focused on sending messages to their friends. As a result, in-lining this data is probably not all that important. On the other hand, the User resource might be quite small, so it may not be a huge problem to in-line this data if it’s often used by the API.
尽管如此,我们还没有解决另一种重要的关系类型:层次结构。让我们看看什么时候选择一个等级关系而不是另一个是有意义的类型。
With all that said, we’ve not yet addressed the other important type of relationship: hierarchy. Let’s look at when it makes sense to choose a hierarchical relationship over another type.
作为我们之前了解到,层次关系是父资源和子资源之间的一种非常特殊的引用关系,而不是两个一般相关的资源。这种关系的最大区别在于动作的级联效应以及行为和属性从父级到子级的继承。例如,删除父资源通常意味着删除子资源。同样,访问(或无法访问)父资源通常意味着对子资源具有相同级别的访问权限。这种关系的这些独特且具有潜在价值的特征意味着它具有巨大的潜力,无论好坏。
As we learned before, hierarchical relationships are a very special type of reference relationship between a parent resource and a child resource rather than two generally related resources. The biggest differences with this type of relationship are the cascading effect of actions and the inheritance of behaviors and properties from parent to child. For example, deleting a parent resource typically implies deletion of a child resource. Likewise, access (or lack of access) to a parent generally implies the same level of access to a child resource. These unique and potentially valuable features of this relationship mean that it has a great deal of potential, for both good and bad.
我们如何决定何时以层级关系而不是简单关系来安排我们的资源是有意义的。假设我们已经决定对这种特定关系进行建模是我们 API 的基础(请参阅第 4.2.1 节),我们实际上可以依赖这些行为作为这种关系是否合适的重要指标。
How do we decide when it makes sense to arrange our resources in a hierarchical relationship rather than a simple relationship. Assuming that we’ve already decided modeling this specific relationship is fundamental to our API (see section 4.2.1), we can actually rely on these behaviors as an important indicator of whether this relationship is a good fit.
例如,当我们删除一个聊天室时,我们几乎肯定也想删除Message属于该聊天室的资源。此外,如果某人被授予访问该ChatRoom资源的权限,那么如果他们也无权查看Message与该聊天室关联的资源,那将毫无意义。这两个指标意味着我们几乎肯定要将这种关系建模为适当的层次结构。
For example, when we delete a chat room we almost certainly also want to delete the Message resources belonging to that chat room. Further, if someone is granted access to the ChatRoom resource, it wouldn’t really make sense if they didn’t also have access to view the Message resources associated with that chat room. These two indicators mean that we’re almost certainly going to want to model this relationship as a proper hierarchy.
还有一些迹象表明您也不应该使用层次关系。由于子资源应该只有一个父资源,我们可以很快知道已经有一个父资源的资源不能再有另一个。换句话说,如果您考虑的是一对多关系,那么层次结构绝对不适合。例如,Message资源应该只属于一个聊天室。如果我们想将单个Message资源与大量ChatRoom资源相关联,层次结构可能不是正确的模型。
There are also some signs that you shouldn’t use a hierarchical relationship as well. Since a child resource should only ever have a single parent, we can quickly know that a resource that already has one parent cannot have another. In other words, if you have a one-to-many relationship in mind, then hierarchy is definitely not a good fit. For example, Message resources should only ever belong to a single chat room. If we wanted to associate a single Message resource with lots of ChatRoom resources, hierarchy is probably not the right model.
这并不是说资源可以指向一个(而且只有一个)资源。毕竟,大多数资源都是另一个资源的子资源,并且仍可能被(或参考)其他资源引用。例如,假设ChatRoom资源属于公司(本示例的新资源类型)。公司可能有保留政策(由RetentionPolicy资源代表) 表示消息在被删除之前会停留多长时间。这些保留策略将是单个公司的子项,但可以被公司中的任何ChatRoom资源引用。如果这让您感到困惑,请查看图 4.12 中的资源布局图。
This isn’t to say that resources can point to one (and only one) resource. After all, most resources will be children of another and may still be referenced by (or reference) other resources. For example, imagine that ChatRoom resources belong to companies (a new resource type for this example). Companies might have retention policies (represented by RetentionPolicy resources) that say how long messages hang around before being deleted. These retention policies would be children of a single company but may be referenced by any of the ChatRoom resources in the company. If that’s got you confused, look at the resource layout diagram in figure 4.12.
图 4.12 显示适用于聊天室的公司范围保留策略的资源布局图
Figure 4.12 A resource layout diagram showing the company-wide retention policies that apply to chat rooms
正如您在此处看到的,User、ChatRoom和RetentionPolicyresources 都是资源的子Company资源. 同样,Message资源是资源的子ChatRoom资源。但RetentionPolicy可重复使用,可应用于许多不同的房间。
As you can see here, User, ChatRoom, and RetentionPolicy resources are all children of a Company resource. Likewise, Message resources are children of the ChatRoom resource. But RetentionPolicy is reusable and can be applied to lots of different rooms.
希望此时您对何时依赖引用与层次结构(以及何时内联信息而不是使用引用作为指针)有了很好的了解。但是在探索了这些主题之后,我们在设计 API 时往往会陷入一些下意识的反应。让我们花点时间看看一些资源布局反模式以及如何避免他们。
Hopefully at this point you have a good idea of when to rely on references versus hierarchies (as well as when to in-line information rather than use a reference as a pointer). But after exploring these topics there are a couple of knee-jerk reactions we tend to fall into when designing our APIs. Let’s take a moment and look at some resource layout anti-patterns and how to avoid them.
作为对于大多数话题,都有一些在任何情况下都容易遵循的普遍行为,但这些行为往往会让我们误入歧途。毕竟,盲目遵循规则比深入思考 API 设计并决定安排资源的最佳方式以及它们如何相互关联要容易得多。
As with most topics, there are common blanket behaviors that are easy to follow under all circumstances, but these behaviors tend to lead us awry. After all, it’s much easier to blindly follow a rule than to think deeply about an API design and decide on the best way to arrange resources and how they relate to one another.
它通常很想为您可能想要在 API 中建模的最微小的概念创建资源。通常,当有人决定成为数据类型的每个概念都必须是适当的资源时,建模过程中就会出现这种情况。例如,假设我们有一个 API,我们可以在其中存储图像注释。对于每个注释,我们可能希望存储包含的区域(可能是某种边界框),以及构成实际注释内容的注释列表。
It can often be tempting to create resources for even the tiniest concept you might want to model in your API. Typically this comes up during modeling when someone decides that every single concept that becomes a data type must be a proper resource. For example, imagine we have an API where we can store annotations on images. For each annotation, we might want to store the area that’s included (perhaps as some sort of bounding box), as well as a list of notes that make up the content of the actual annotation.
此时,我们需要考虑四个独立的概念:图像、注释、边界框和注释。问题变成了,其中哪些应该是资源,哪些应该在成为唯一数据类型时结束它们的旅程?一个下意识的反应是无论如何都要让一切都成为资源。这最终可能看起来类似于图 4.13 中所示的资源布局图。
At this point, we have four separate concepts to consider: images, annotations, bounding boxes, and notes. The question becomes, which of these should be resources and which should end their journey at the point of becoming only data types? One knee-jerk reaction is to make everything a resource, no matter what. This might end up looking something like the resource layout diagram shown in figure 4.13.
Figure 4.13 A resource layout diagram showing all concepts built as resources
现在我们有四个完整的资源,它们具有完整的资源生命周期。换句话说,我们需要为这些资源中的每一个实现五个不同的方法,总共需要 20 个不同的 API 调用。它留下了一个问题:这是正确的选择吗?毕竟,我们真的需要给每个边界框一个自己的标识符吗?我们真的需要为每个单独的笔记提供单独的资源吗?
Now we have four complete resources that have the full resource life cycle. In other words, we need to implement five different methods for each of these resources, totaling 20 different API calls. And it leaves the question: is this the right choice? After all, do we really need to give each bounding box its own identifier? And do we really need separate resources for each individual note?
如果我们仔细思考一下这个布局,我们可能会发现两件重要的事情。一、BoundingBox资源与注解是一对一的,因此将它放入单独的资源中几乎没有任何价值。其次,Note资源列表不太可能将变得异常大,因此我们也不会通过将这些表示为资源来获得那么多。如果我们考虑到这一点,我们的资源布局就会简单得多。
If we think a bit harder about this layout, we might find two important things. First, the BoundingBox resource is one-to-one with an annotation, so we get almost no value from putting it into a separate resource. Second, it’s unlikely that the list of Note resources will get exceptionally large, so we don’t gain all that much by representing these as resources, either. If we take that into consideration, we have a much simpler resource layout.
Listing 4.2 Simplified interfaces relying on in-line representations
interface Image { ❶ id: string; } interface Annotation { ❶ id: string; boundingBox: BoundingBox; notes: Note[]; } interface BoundingBox { ❷ bottomLeftPoint: Point; topRightPoint: Point; } interface Point { ❷ x: number; y: number; } interface Note { ❷ content: string; createTime: Date; }
❶ These two interfaces are actual resources (notice the identifier field).
❷这些都不是资源;它们只是在 Annotation 资源内的 API 中出现的数据类型。
❷ All of these are not resources; they are just data types that are surfaced in the API inside the Annotation resource.
这意味着我们实际上只有两种资源需要考虑(而不是四种),这更容易理解和构建。图 4.14 显示了更简化版本的资源布局图。
This means that we actually only have two resources to think about (instead of four), which is much easier both to understand and build. Figure 4.14 shows the resource layout diagram in its more simplified version.
Figure 4.14 Resource layout diagram of two simple resources rather than four
这里的一个好的经验法则是避免两个问题。首先,如果您不需要独立于与它相关联的资源与您建议的资源之一进行交互,那么您可能会认为它只是一种数据类型。在此示例中,不太可能有人需要在图像本身的上下文之外操作边界框,这意味着这可能作为一种数据类型很好。
A good rule of thumb here is to avoid two issues. First, if you don’t need to interact with one of your proposed resources independent of a resource it’s associated with, then you might be fine with it being just a data type. In this example, it’s unlikely that someone would need to manipulate the bounding box outside of the context of the image itself, meaning this is probably fine as a data type.
其次,如果您正在考虑的概念是您可能想要直接与之交互的东西(在这种情况下,您可能想要删除一个音符或单独创建新的音符),如果它能够内联(请参阅部分4.2.2) 那么这可能是一个不错的选择。在这种情况下,Note资源足够小,预计不会为每个注释增长到一个大集合,这意味着它们可能是安全的内联而不是表示为独立的资源。
Second, if the concept you’re thinking of is something you might want to interact with directly (in this case, you might want to delete one single note or create new ones individually), if it’s able to be in-lined (see section 4.2.2) then that might be a good choice. In this case, Note resources were sufficiently small and not expected to grow into a large collection for each annotation, meaning they’re probably safe to in-line rather than represent as independent resources.
这下一个要避免的常见反模式是关于层次结构的。层次关系常常显得如此强大和有用,以至于我们试图在任何可能的地方使用它们。但是,过深的层次结构可能会使所有相关人员感到困惑和难以管理。
The next common anti-pattern to avoid is specifically about hierarchies. Often hierarchical relationships can appear so incredibly powerful and useful that we try to use them everywhere we can. However, overly deep hierarchies can be confusing and difficult to manage for everyone involved.
例如,假设我们正在构建一个系统来管理大学的课程目录。我们需要跟踪不同的大学、课程所在的学校或项目(例如,护士学校或工程学校)、可用的课程、开设课程的学期以及各种课程文件(例如,教学大纲)。跟踪所有这些的一种方法是将所有内容放在一个层次结构中,如图 4.15 所示。
For example, let’s imagine that we’re building a system to manage course catalogs for universities. We need to keep track of the different universities, the school or program in which the course exists (e.g., the nursing school or the engineering school), the courses that are available, the semester in which the course is offered, and the various course documents (e.g., the syllabus). One way to keep track of all this is to put everything together in a single hierarchy, shown in figure 4.15.
Figure 4.15 Course catalog with a deep hierarchy
虽然这在技术上会很好地工作,但很难推理并记住所有不同的父母。这也意味着我们现在需要了解大量信息才能找到单个文档(有关此主题的更多讨论,请参见第 6.3.6 节),这可能很难存储和调用。但这里更大的问题是,在这里使用层次结构的好处并不是完成工作所必需的。换句话说,我们可以创建一个没有那么多层次层次的 API。
While this technically will work just fine, it’s pretty difficult to reason about and keep all the different parents in our minds. It also means that we now need to know quite a lot of information in order to find a single document (see section 6.3.6 for more discussion on this topic), which can be challenging to store and recall. But the bigger problem here is that the benefits of using hierarchy here aren’t really necessary to get the job done. In other words, we can probably create just as good of an API without so many levels of hierarchy.
如果我们决定关键部分是大学、课程和文档怎么办?在那个世界里,学校变成了课程的一个领域,学期也是如此。我们通过询问层次结构的学校和学期级别是否真的那么重要来做到这一点。
What if we decided that the key pieces are universities, courses, and documents? In that world, the school becomes a field on the course, as does the semester. We do this by asking whether the school and semester levels of the hierarchy are really all that critical.
例如,我们打算将分层行为用于什么目的?我们是否计划经常删除学校?(可能不是。)学期呢?(也可能不是。)最可能的情况是我们希望看到列出给定课程的所有学期。但是我们可以通过基于课程标识符进行过滤来实现这一点,同样可以通过给定的学校 ID 列出课程。
For example, what are we planning to use the hierarchical behavior for? Do we plan to delete schools often? (Probably not.) What about semesters? (Also, probably not.) The most likely scenario is that we’d like to see all semesters that a given course was listed. But we can do this by filtering based on a course identifier and likewise for listing courses by a given school ID.
请记住,这并不是说我们应该完全摆脱这些资源。在学期的情况下,我们不太可能需要可用学期的列表来进行探索,但学校可能是值得保留的东西。建议的改变是将学校和课程之间的关系从等级关系改变为参考关系。这个新的(较浅的)层次结构如图 4.16 所示。
Keep in mind that this isn’t to say we should get rid of these resources entirely. In the case of a semester, it’s unlikely that we need a listing of the available semesters for exploratory purposes, but schools might be something worth keeping around. The suggested change is instead to change the relationship between schools and courses from a hierarchical one to a referential one. This new (shallower) hierarchy is shown in figure 4.16.
Figure 4.16 Course catalog with a shallower hierarchy
通过以这种方式安排我们的资源(即,更浅),我们仍然可以做一些事情,比如授予对课程的给定实例的所有文档的访问权限或列出给定学期的所有课程,但我们已经将层次结构削减到真正受益的作品他们。
By arranging our resources in this way (i.e., shallower), we can still do things like grant access to all documents for a given instance of a course or list all courses for a given semester, but we’ve whittled the hierarchy down to the pieces that really benefit from them.
和非关系数据库的出现,特别是大规模键值存储系统,通常倾向于去规范化(或内联)所有数据,包括那些在过去会整齐地组织成单独的数据与花哨的 SQL 查询连接的表。虽然有时将信息折叠到一个资源中而不是将所有内容都变成自己的资源是有意义的(正如我们在 4.3.2 节中了解到的那样),但将太多信息折叠到一个资源中可能与分离每条信息一样有害除了自己的资源。
With the advent of nonrelational databases, in particular large-scale key-value storage systems, there is often the tendency to de-normalize (or in-line) all data, including that which would, in the past, have been neatly organized into individual tables to be joined with fancy SQL queries. And while sometimes it makes sense to fold information into a single resource rather than making everything its own resource (as we learned in section 4.3.2), folding too much information into a single resource can be just as detrimental as separating every piece of information apart into its own resource.
Figure 4.17 Books with Authors as a separate resource
一个重要原因与数据完整性有关,这也是非关系数据库中非规范化模式的常见问题。例如,假设我们正在构建一个图书馆 API,用于存储不同作者撰写的书籍。一种选择是我们有两个资源,Book和Author,其中Book资源指向Author资源,如图 4.17 所示。如果我们改为内联此信息,我们可能会将作者姓名(和其他信息)直接存储在Book资源中,如图 4.18 所示。
One big reason for this has to do with data integrity, which is also a common problem with de-normalized schemas in nonrelational databases. For example, let’s imagine that we’re building a library API that stores books written by different authors. One option would be for us to have two resources, Book and Author, where Book resources point to Author resources, shown in figure 4.17. If we were to in-line this information instead, we might store the author names (and other information) directly on the Book resource, shown in figure 4.18.
Figure 4.18 Books modeled with Author information in-line
正如我们之前了解到的(请参阅第 4.2.2 节),像这样的内联数据可能非常有价值,但它也可能导致一些实际问题。例如,当您更新其中一位作者的姓名时,是否会在他们所写的所有书中更新该姓名?这个问题的答案可能取决于您的实施,但我们不得不问这个问题意味着它可能会引起混淆。换句话说,当我们嵌入仍将在其他资源之间共享的数据(例如,写过几本书的作者)时,我们打开了可怕的蠕虫罐头,我们必须决定 API 的用户打算如何更新共享数据(例如,作者姓名)。虽然有解决这些问题的好方法,但这不是我们应该担心的问题第一的地方。
As we learned before (see section 4.2.2), in-lining data like this can be quite valuable, but it also can lead to some real problems. For example, when you update the name of one of the book’s authors, does it update that name across all the books they’ve written? The answer to this will likely depend on your implementation, but the fact that we have to ask that question means that it’s likely to cause confusion. In other words, when we in-line data that will still be shared across other resources (e.g., authors who have written several books), we open the scary can of worms where we have to decide how users of the API are intended to update that shared data (e.g., authors’ names). And while there are good ways to resolve those issues, it’s just not a question we should have to worry about in the first place.
想象您正在构建一个书签 API,您可以在其中为特定的在线 URL 设置书签,并且可以将它们安排在不同的文件夹中。您是将其建模为两个单独的资源(Bookmark和Folder)还是单个资源(例如Entity),并使用内联类型来说明它是充当文件夹还是书签?
Imagine you’re building a bookmark API where you have bookmarks for specific URLs online and can arrange these in different folders. Do you model this as two separate resources (Bookmark and Folder) or a single resource (e.g., Entity), with a type in-line to say whether it’s acting as a folder or a bookmark?
Come up with an example and draw an entity relationship diagram that involves a many-to-many relationship, a one-to-many relationship, a one-to-one relationship, and an optional one-to-many relationship. Be sure to use the proper symbols for the connectors.
Resource layout refers to the arrangement and relationships between resources in an API.
While it might be tempting to connect every resource in an API, fight the urge and only store relationships if they provide important functionality to the API.
有时为概念存储单独的资源是有意义的。其他时候最好内联该数据并将概念保留为数据类型。该决定取决于您是否需要与该概念进行原子交互。
Sometimes it makes sense to store a separate resource for a concept. Other times it’s better to in-line that data and leave the concept as a data type. The decision depends on whether you need to atomically interact with that concept.
Avoid overly deep hierarchical relationships as they can be difficult to comprehend and manage.
null值与完全缺失的值有何不同null value differs from one that’s missing entirely在设计任何 API 时,我们总是必须考虑我们想要接受、理解和可能存储的数据类型。有时这听起来很简单:一个名为“name”的字段可能只是一串字符。然而,隐藏在这个问题中的是一个复杂的世界。例如,字符串应该如何表示为字节(事实证明有很多选择)?如果在 API 调用中省略名称会怎样?这与提供空名称(例如,{ "name": "" })有什么不同吗?在本章中,我们将探讨在设计或使用 API 时几乎肯定会遇到的各种数据类型,如何最好地理解它们的底层数据表示,以及如何以理智和直接的方式最好地处理各种类型的默认值。
When designing any API, we always have to think of the types of data we want to accept as input, understand, and potentially store. Sometimes this sounds pretty straightforward: a field called “name” might just be a string of characters. Hidden in this question though is a world of complexity. For example, how should the string of characters be represented as bytes (it turns out there are lots of options for this)? What happens if the name is omitted in an API call? Is that any different from providing an empty name (e.g., { "name": "" })? In this chapter we’ll explore the various data types you will almost certainly experience when designing or using APIs, how best to understand their underlying data representation, and how best to handle the default values of the various types in a sane and straightforward way.
数据类型是几乎所有编程语言的一个重要方面,它告诉程序如何处理一大块数据,以及它应该如何与语言或类型本身提供的各种运算符交互。甚至在具有动态类型的语言中(其中单个变量可以充当许多不同的数据类型,而不是仅限于单个数据类型),单个时间点的类型信息仍然非常重要,它决定了编程语言应该如何对待变量。
Data types are an important aspect of almost every programming language, telling the program how to handle a chunk of data and how it should interact with the variety of operators provided by the language or the type itself. And even in languages with dynamic typing (where a single variable can act as lots of different data types rather than being restricted to a single one), the type information at a single point in time is still quite important, dictating how the programming language should treat the variable.
但是,在设计API时,我们必须打破单一编程语言的思维模式,主要是因为我们API的一个主要目标是让任何人,用任何语言编程,都可以与服务交互。我们这样做的标准方法是依赖于一些序列化协议,它采用我们选择的编程语言的数据结构化表示,并将其转换为与语言无关的序列化字节表示,然后再将其发送给请求它的客户端。在另一端,客户端反序列化这些字节并将它们转换回内存中的表示形式,他们可以通过他们使用的语言(可能与我们的语言相同也可能不同)与之交互。有关此过程的概述,请参见图 5.1。
However, when designing APIs, we have to break out of the mode of thinking of a single programming language primarily because a major goal of our API is to let anyone, programming in any language, interact with the service. The standard way we do that is by relying on some serialization protocol, which takes a structured representation of data in our programming language of choice and converts it into a language-agnostic representation of serialized bytes before sending it to a client who asked for it. On the other end, the client deserializes these bytes and converts them back into an in-memory representation that they can interact with from the language they use (which may or may not be the same as ours). See figure 5.1 for an overview of this process.
Figure 5.1 Data moving from API server to client
虽然此序列化过程提供了巨大的好处(本质上允许任何编程语言使用 API),但它并非没有缺点。最大的一个问题是,由于每种语言的行为有所不同,一些信息会在翻译中丢失。换句话说,由于不同编程语言在特性和功能上的差异,所有序列化协议都会以这种或那种方式“有损”。
While this serialization process provides a huge benefit (essentially allowing an API to be used by any programming language), it’s not without its downsides. The biggest one is that some information will be lost in translation due to the fact that every language acts somewhat differently. In other words, due to the difference in features and functionality of different programming languages, all serialization protocols will be “lossy” in one way or another.
这让我们回到数据类型的重要性以及如何在 API 中使用它们。简而言之,在考虑那些将使用 Web API 的人时,依赖我们选择的编程语言提供的数据类型从根本上是不够的。相反,我们必须考虑我们选择的序列化格式(最常见的是 JSON)提供的数据类型,以及在它不符合我们需求的情况下我们如何扩展该格式。这意味着我们需要决定我们要发送给客户和从客户那里接收的数据类型,并确保它们有足够的记录,这样客户就不会对他们的行为感到惊讶。
This leads us back to the importance of data types and how to use them in an API. Put simply, it’s fundamentally insufficient to rely on the data types provided by our programming language of choice when thinking about those who will use a web API. Instead, we have to think in terms of the data types offered by our chosen serialization format (most commonly JSON) and how we might extend that format in cases where it doesn’t stack up with our needs. This means we’ll need to decide on the types of the data we want to send to and receive from clients and ensure they are documented well enough so that clients are never surprised by their behavior.
这并不是说 Web API 必须具有与关系数据库系统相似的严格模式;毕竟,现代开发工具最强大的功能之一就是动态无模式结构和数据存储提供的灵活性。然而,重要的是要考虑我们正在交互的数据类型,必要时用额外的注释来阐明它。如果没有这种额外的数据上下文,我们可能会陷入困境,猜测客户的实际意图。例如,a + b可能对数值采取一种方式(例如,2 + 4可能导致6),但对文本值可能采取完全不同的方式(例如,"2" + "4"可能导致"24")。如果没有这种类型上下文,我们将被迫猜测客户端使用+运算符:是加法还是拼接?如果一个值是数字而另一个是字符串怎么办?这可能会导致更多的猜测。但是如果一个值被完全省略了呢?
This is not to say that a web API must have a strict schema along the lines of a relational database system; after all, one of the most powerful things from modern development tools is the flexibility provided by dynamic schema-less structures and data storage. However, it’s important to consider the type of the data we’re interacting with, clarifying it with additional annotations when necessary. Without this extra context on data, we may end up stuck, guessing about the actual intent of a client. For example, a + b might act one way for numeric values (e.g., 2 + 4 might result in 6) but may behave completely differently for text values (e.g., "2" + "4" might result in "24"). Without this type context we’d be forced to make a guess about the intent when a client uses the + operator: is it addition or concatenation? And what if one value is a number and the other is a string? This can lead to even more guesswork. But what if a value is omitted entirely?
出奇,最令人困惑的方面之一来自数据丢失而不是存在的情况。首先,在许多序列化格式中都有一个null值,它是一个指示非值的标志(例如,字符串可以是value或 literal null)。那么,当尝试添加(例如,null和)时,API 应该如何表现2?任何使用支持null此类值的序列化格式的 API 都需要决定如何最好地处理对其 API 的此类输入。它应该假装它null在数学上等同于0吗?尝试添加nulland"value"怎么样?在这种情况下应该null解释为空字符串 ( "") 并尝试连接它吗?
Surprisingly, one of the most confusing aspects arises from the scenario when data is missing rather than present. First, in many serialization formats there’s a null value, which is a flag indicating a non-value (e.g., a string could be value or the literal null). So how should an API behave when trying to add, for example, null and 2? Any API using a serialization format that supports a null value like this will need to decide how best to handle this type of input to their API. Should it pretend that null is mathematically equivalent to 0? What about trying to add null and "value"? In this scenario should null be interpreted as the empty string ("") and try to concatenate it?
更糟糕的是,动态数据结构(例如,JSON 对象)有一个新问题:如果值根本不存在怎么办?换句话说,不是恰好明确设置为 provided 的值null,如果存储该值的键完全丢失怎么办?这是否与显式值一样对待null?要了解这意味着什么,请假设您有一个Fruit资源,您希望它包含名称和颜色,这两种数据类型都是字符串数据类型。考虑以下示例 JSON 对象和 API 可能识别资源颜色值的值:
To make things worse, dynamic data structures (e.g., a JSON object) have a new concern: what if the value is simply not present? In other words, rather than a value provided that happens to be set explicitly to null, what if the key where that value would be stored is missing entirely? Is that treated the same as an explicitly null value? To see what this means, imagine you have a Fruit resource that you expect to contain a name and a color, both string data types. Consider the following example JSON objects and the value an API might discern for the resource’s color value:
fruit1 = { name: "Apple", color: "red" }; fruit2 = { name: "Apple", color: "" }; fruit3 = { name: "Apple", color: null }; fruit4 = { name: "Apple" };
如您所见,第一个颜色值很明显 ( fruit1.color == "red")。但是,我们应该如何处理其他人呢?空颜色 ( "") 是否被视为与显式null颜色相同(与 有fruit2.color任何不同的处理方式fruit3.color)?颜色值缺失的水果怎么办?fruit3.color对待有什么不同吗fruit4.color?这些可能看起来像是 JSON 的怪癖;然而,它们确实以许多其他序列化格式存在(例如,Google 的 Protocol Buffers [ https://opensource.google/projects/protobuf] 在这方面有一些令人困惑的行为),他们提出了每个 API 都必须解决的场景,否则可能会导致使用 API 的人非常不一致和困惑。换句话说,您不能简单地假设序列化库会做正确的事情,因为几乎可以肯定每个人都会使用不同的序列化库!
As you can see, the first color value is obvious (fruit1.color == "red"). However, what should we do about the others? Is the empty color ("") treated the same as an explicit null color (is fruit2.color treated any differently from fruit3.color)? What about the fruit with the missing color value? Is fruit3.color treated any different from fruit4.color? These might just seem like quirks of JSON; however, they do exist in lots of other serialization formats (e.g., Google’s Protocol Buffers [https:// opensource.google/projects/protobuf] has some confusing behavior in this area), and they present scenarios that every API must address or risk being wildly inconsistent and confusing for those using the API. In other words, you can’t simply assume that the serialization library will do the right thing because it’s almost a certainty that everyone will be using different serialization libraries!
在本章中,我们将从简单的数据类型(有时称为原语) 并向更复杂的方向发展(例如地图和集合)。您应该已经熟悉其中的大部分概念,因此重点将较少关注每种数据类型的基础知识,而是更多关注将它们与 Web API 一起使用的必要注意事项。我们将深入研究不同的“陷阱”以寻找每种数据类型以及如何最好地解决任何 API 的问题。我们还将坚持将 JSON 作为选择的序列化格式,但大多数建议将适用于支持动态数据结构的任何格式,同时使用推断和显式类型定义。让我们从最简单的开始:真和错误的值。
In this chapter, we’ll go through all the common data types, starting with the simple ones (sometimes known as primitives) and moving toward the more complex (such as maps and collections). You should be already familiar with most of these concepts, so the focus will be less about the basics of each data type and more about the necessary considerations for using them with web APIs. We’ll dig into the different “gotchas” to look out for with each data type and how best to address them for any API. We’ll also stick to JSON as the serialization format of choice, but most of the recommendations will apply to any format that supports dynamic data structures, using both inferred and explicit type definitions. Let’s start with the simplest of all: true and false values.
在在大多数编程语言中,布尔值是可用的最简单的数据类型,表示两个值之一:true或false。由于这个有限的值集,我们倾向于依赖布尔值来存储标志或简单的是或否问题。例如,我们可能会在聊天室中存储一个标志,说明该房间是否已存档或是否允许聊天机器人进入该房间。
In most programming languages, Booleans are the simplest data type available, representing one of two values: true or false. Due to this limited value set, we tend to rely on Boolean values for storing flags or simple yes or no questions. For example, we might store a flag on a chat room about whether the room is archived or whether chat bots are allowed in the room.
Listing 5.1 A ChatRoom resource with a Boolean flag
interface ChatRoom { id: string; // ... ❶ archived: boolean; ❷ allowChatbots: boolean; ❸ }
❶ Here we would have lots of other fields for the chat room.
❷ This flag marks whether a chat room has been archived.
❸ This makes a statement about whether chat bots are permitted in a chat room.
但是,布尔标志在未来的情况下可能会受到很大限制,在这种情况下,是或否的问题可能会成为一个更普遍的问题,需要比布尔字段所能提供的更细致的答案。例如,未来聊天室可能会允许许多不同类型的参与者,而不仅仅是普通用户和聊天机器人。在这种情况下,我们当前的设计将导致列出每种允许(或不允许)的类型的长列表,例如allowChatbots、allowModerators、allowChildren、allowAnonymousUsers等等。如果这是可能的,那么避免使用布尔标志的集合实际上可能更有意义,而是依赖不同的数据类型或结构来定义允许或不允许来自特定聊天室的参与者类别。
However, Boolean flags can be quite limiting in future cases where a yes or no question may become a more general question that requires a more nuanced answer than a Boolean field can provide. For example, there may be a future where chat rooms allow lots of different types of participants rather than just normal users and chat bots. In this scenario, our current design would lead to a long list of every type allowed (or disallowed), such as allowChatbots, allowModerators, allowChildren, allowAnonymousUsers, and so on. If this is a possibility, it may actually make more sense to avoid using a collection of Boolean flags and instead rely on a different data type or structure to define the categories of participants allowed or disallowed from a particular chat room.
假设布尔字段是变量的正确选择,还有一些其他有趣的方面需要考虑。隐藏在布尔字段名称中的是该字段是正数还是负数的声明。在 的示例中allowChatbots,true该字段的值表示允许聊天机器人进入聊天室。但它可能很容易成为禁止聊天disallowChatbots的值。true来自聊天室的机器人。为什么选择一个或另一个?
Assuming a Boolean field is the right choice for a variable, there are some other interesting aspects to consider. Hidden in the name of Boolean fields is a statement of whether the field is positive or negative. In the example of allowChatbots, the true value for the field indicates that a chat bot is allowed into the chat room. But it could just as easily have been disallowChatbots where the true value would prohibit chat. bots from the chat room. Why choose one or the other?
一般来说,正布尔字段对我们大多数人来说最容易理解,原因很简单:人类必须更加认真地思考双重否定。负字段的错误值就是这样。例如,假设该字段是disallowChatbots. 您如何检查给定聊天室中是否允许聊天机器人?
In general, positive Boolean fields are easiest for most of us to understand for a simple reason: humans have to think a bit harder about double negatives. And the false value for a negative field is just that. For example, imagine the field was disallowChatbots. How do you check whether chat bots are permitted in a given chat room?
Listing 5.2 A function to add a chatbot to a room, if permitted
function addChatbotToRoom(chatbot: Chatbot, roomId: number): void { let room = getChatRoomById(roomId); if (room.disallowChatbots === false) { ❶ chatbot.join(room.id); } }
❶在这里我们可以检查聊天机器人是否被禁止。如果为假,我们可以加入房间。
❶ Here we can check whether chat bots are disallowed. If false, we can join the room.
这是不可能遵循的吗?可能不会。它比更简单的版本 ( if (room.allowChatbots) { ... }) 有更多的认知负担吗?对于我们大多数人来说,可能。仅基于这一点,使用正布尔标志几乎总是一个好主意。但是在某些情况下,依赖否定标志可能是有意义的,主要是当我们考虑字段的默认值或未设置值时。
Is this impossible to follow? Probably not. Is it a bit more cognitive load than the much simpler version (if (room.allowChatbots) { ... })? For most of us, probably. Based on that alone, it’s almost always a good idea to go with positive Boolean flags. But there are scenarios when it might make sense to rely on negative flags, primarily when we consider the default or unset value of the field.
例如,在某些语言或序列化格式(例如,直到最近,Google 的 Protocol Buffers v3)中,许多原语(例如布尔字段)只能有一个zero值而不是一个null或missing值。您可能会猜到,对于布尔值,此zero值几乎总是等于false. 这意味着当我们在allowChatbots创建聊天室时考虑该字段的值时,在没有任何进一步干预的情况下,聊天室不允许聊天机器人 ( defaultChatRoom .allowChatbots == false)。但是我们无法区分用户说“我没有设置这个值,所以请做你认为最好的”(即 anull或missing值)和“我不想允许聊天机器人”(即 an明确false的价值)。在这两种情况下,值都是简单的false。我们可以做什么?
For example, in some languages or serialization formats (e.g., until recently, Google’s Protocol Buffers v3) many primitives, such as Boolean fields, could only ever have a zero value rather than a null or missing value. As you might guess, for Boolean values this zero value almost always equates to false. This means that when we consider the value of the allowChatbots field when a chat room is created, without any further intervention the chat room does not allow chatbots (defaultChatRoom .allowChatbots == false). But we cannot distinguish between the user saying, “I didn’t set this value, so please do what you think is best” (i.e., a null or missing value) from “I don’t want to allow chat bots” (i.e., an explicit false value). In both cases the value is simply false. What can we do?
虽然这个问题有很多解决方案,但一个常见的选择是依靠布尔字段名称的正面或负面方面来确定“正确”的默认值。换句话说,我们可以为布尔字段选择一个名称,以便该zero值提供我们正在寻找的默认值。例如,如果我们希望默认允许匿名用户,我们可能决定命名该字段disallowAnonymousUsers,以便zero值 ( false) 将导致我们想要的默认值(允许匿名用户)。不幸的是,这种选择将默认值锁定在字段的名称中,这禁止了未来的灵活性(即,您不能更改默认值),但是,正如他们所说,绝望的时代呼唤绝望措施。
While there are many solutions to this problem, one common choice is to rely on the positive or negative aspect of the Boolean field’s name to land on the “right” default value. In other words, we can choose a name for a Boolean field such that the zero value provides the default we’re looking for. For example, if we want anonymous users to be allowed by default, we might decide to name the field disallowAnonymousUsers so that a zero value (false) would result in the default we want (anonymous users allowed). This choice, unfortunately, locks the default into the name of the field, which prohibits future flexibility (i.e., you cannot change the default down the line), but, as they say, desperate times call for desperate measures.
获得比简单的是或否问题稍微复杂一些,数值允许我们存储各种有价值的信息,例如计数(例如,viewCount)、大小和距离(例如,itemWeight)或货币值(例如,priceUsd)。一般来说,任何我们可能想要对其执行算术计算的东西(或者即使这些计算只具有逻辑意义),数字字段都是数据类型的理想选择。
Getting slightly more complex from the simple yes or no questions, numerical values allow us to store all sorts of valuable information such as counts (e.g., viewCount), sizes and distances (e.g., itemWeight), or currency values (e.g., priceUsd). In general, anything that we might want to perform arithmetic calculations on (or even if these calculations would only make logical sense), number fields are the ideal choice of data type.
有一个值得注意的例子实际上并不适合这种数据类型:数字标识符。这可能令人惊讶,因为许多数据库系统(以及几乎所有关系数据库系统)都使用自动递增的整数值作为其行的主键标识符。那么为什么我们不对 API 中的标识符做同样的事情呢?
There is one notable example that is actually not a good fit for this data type: numeric identifiers. This might be surprising as many database systems (and almost all relational database systems) use automatically incrementing integer values as primary key identifiers for their rows. So why wouldn’t we do likewise with identifiers in an API?
尽管出于各种原因(例如,性能或空间限制),我们可能仍在后台使用数字字段,但在 API 表面数字类型最好用于提供某种形式的算术优势的值,而这些值是实际数字. 换句话说,如果 API 公开具有以克为单位的重量值的项目,我们可以想象将所有这些值相加以确定一组这些项目的组合重量(以克为单位)。另一方面,添加一组数字标识符并使用该值执行某些操作通常没有任何意义。相反,添加一堆数字 ID 可能毫无用处。因此,重要的是要依靠数字数据类型来获得数字或算术上的好处,而不是因为它们恰好只用数字字符(可能在这里或那里有一个小数点)来编写。一般而言,那些恰好看起来像数字但表现得更像标记或符号的值应该是字符串值(请参阅第 5.4 节)。
While we might still use a numeric field under the hood for a variety of reasons (e.g., performance or space constraints), in an API surface numeric types are best used for values that provide some form of arithmetic benefit out of the values being actual numbers. In other words, if an API exposes items that have a weight value in grams, we might conceivably add all of these values to determine the combined weight in grams of a set of these items. On the other hand, it generally doesn’t make any sense to add a set of numeric identifiers and do something with that value. On the contrary, the addition of a bunch of numeric IDs is likely to be pretty useless. As a result, it’s important to rely on numeric data types for their numeric or arithmetic benefit and not for the fact that they happen to be written with only the numeral characters (perhaps with a decimal point here or there). In general, values that only happen to look like numbers but behave more like tokens or symbols should probably be string values instead (see section 5.4).
尽管如此,无论何时在 API 中使用数值,都需要考虑一些事项。让我们首先看看我们如何为这些数字字段定义可接受值的边界或范围。
With all that said, whenever using numeric values in an API, there are a few things to consider. Let’s start by looking at how we define boundaries or ranges of acceptable values for these numeric fields.
什么时候定义数字字段的边界,无论它是整数还是小数(或其他一些数学表示形式,如分数、虚数等),重要的是要考虑上限或最大值、下限或最小值,和零值。由于这些都相互影响,让我们考虑绝对值范围,然后决定是否利用该范围的负值和正值。
When defining boundaries for a numeric field, whether it’s a whole number or a decimal (or some other mathematical representation such as fractions, imaginary numbers, etc.), it’s important to consider the upper bound or maximum value, the lower bound or minimum value, and the zero value. Since these each have an effect on one another, let’s consider the absolute value bounds and then decide whether to take advantage of both the negative and positive sides of that range.
说到界限,我们主要关注的是它们的大小。换句话说,所有数字最终都需要存储在某个地方,这意味着我们需要知道为这些值分配多少空间(以位为单位)。对于小整数,可能 8 位就足够了,有 256 个可能的值(–127 到 127)。对于大多数常见数值,32 位通常是可接受的大小,大约有 40 亿个可能值(从负 20 亿到正 20 亿)。对于我们可能关心的大多数事情,64 位是一个安全大小,大约有 18 quintillion 的可能值(从负 9 quintillion 到正 9 quintillion)。当我们引入浮点数时,这个范围会变得有点混乱,因为有很多不同的表示形式,这些值的存储范围高达 256 位,
When it comes to bounds, our main focus is their size. In other words, all numbers ultimately need to be stored somewhere, and this means we need to know how much space (in bits) to allocate for these values. For small integers, perhaps 8 bits is enough, with 256 possible values (–127 to 127). For most common number values, 32 bits is typically an acceptable size, with about 4 billion possible values (from negative 2 billion to positive 2 billion). For most things we could care about, 64 bits is a safe size, with about 18 quintillion possible values (from negative 9 quintillion to positive 9 quintillion). This range gets a bit more confusing when we introduce floating point numbers, as there are lots of different representations, ranging up to 256 bits of storage for these values, but the point is that there is typically a representation that will work for the range you have in mind for your API.
虽然这些都是很好的可能性,但您可能想知道为什么我们完全不关心将存在于某个数据库中的数字的大小。毕竟,API 的目的不就是抽象掉所有这些东西吗?那是对的; 然而,这很重要,因为不同的计算机和不同的编程语言处理数字的方式远非统一。例如,JavaScript 甚至没有一个合适的整数值,而只有一个数字类型来处理该语言的所有数值。此外,许多语言以非常不同的方式处理非常大的数字。例如,在 Python 2 中,该int类型能够存储 32 位整数,而longtype 可以处理任意大数,至少存储 36 位。简而言之,这意味着如果 API 响应要向消费者发送非常大的数字,则接收端的语言可能无法正确解析和理解它。此外,这些潜在的巨大数字的接收端需要了解分配多少空间来存储它们。简而言之,边界将非常重要。
While these are all fine possibilities, you may be wondering why we’re paying any mind at all to the sizes of numbers that will live in a database somewhere. After all, isn’t the point of APIs to abstract away all this stuff? That is correct; however, it’s important because the way different computers and different programming languages handle numbers is far from uniform. For example, JavaScript doesn’t even have a proper integer value but instead only has a number type that handles all numeric values for the language. Further, many languages handle extraordinarily large numbers in very different ways. For example, in Python 2 the int type is capable of storing 32-bit integers whereas the long type can handle arbitrary large numbers, storing a minimum of 36 bits. In short this means that if an API response were to send a very large number to a consumer, there’s a possibility that the language on the receiving end might not be able to properly parse and understand it. Further, those on the receiving end of these potentially huge numbers need an idea of how much space to allocate to store them. In short, the bounds are going to be quite important.
因此,通常的做法是在内部对整数依赖 64 位整数类型,除非有充分的理由不这样做。一般而言,即使您目前可能不需要接近 64 位范围的任何地方,软件中的绝对确定性也很少见,因此设置上限和下限并留有增长空间会更安全时间。
As a result, the common practice is to rely on 64-bit integer types for whole numbers internally unless there’s a great reason not to. In general, even when you might not need anywhere near the 64-bit range at the moment, absolute certainties in software are rare so it’s much safer to set upper and lower bounds with room for growth over time.
作为我们在 5.1.1 节中了解到,对于大多数数据类型,我们还需要考虑其他场景,特别是一个null值和一个missing字段被简单省略的值,就像布尔值(5.2 节)一样,但在不提供的序列化格式中一种原语保存null值的机制,我们将无法区分0(or 0.0) 的真实值和用户说的默认值,“我在这里没有意见,所以你可以选择最好的为了我。”
As we learned in section 5.1.1, with most data types we also have additional scenarios to consider, specifically a null value and a missing value where the field is simply omitted, just like Booleans (section 5.2), but in serialization formats that don’t provide a mechanism for primitives to hold a null value, we’ll have no way of distinguishing between a true value of 0 (or 0.0) and a default value where a user is saying, “I don’t have an opinion here so you can choose what’s best for me.”
虽然可以依赖该zero值作为默认标志,但由于几个原因,这是有问题的。首先,作为 API 的一部分,零值可能实际上是有意义和必要的;但是,如果我们将它用作默认值的指示器,我们将无法获得实际zero值。换句话说,如果我们用0as 表示“做最好的事”(在本例中可能是 的值57),我们就无法实际指定一个有意的值0. 其次,特别是在零值可能具有逻辑意义的情况下,使用此值作为标志可能会造成混淆并导致意想不到的后果,从而违反了良好 API 的一些关键原则(在本例中为可预测性)。要回答处理数值默认值的问题,我们实际上必须换档并更详细地讨论这些值应该如何处理序列化。
While it’s possible to rely on the zero value as a flag for the default, this is problematic for a couple of reasons. First, a value of zero might actually be meaningful and necessary as part of the API; however, if we use it as an indicator of a default we’re prevented from having an actual zero value. In other words, if we use 0 as a way of saying “Do whatever is best” (which in this case might be a value of 57), we have no way to actually specify an intentional value of 0. Second, especially in cases where a value of zero might make logical sense, using this value as a flag can be confusing and lead to unintended consequences, violating some of the key principles of good APIs (in this case, predictability). To answer the question of handling defaults for numeric values, we actually have to switch gears and talk in more detail about how these values should be serialized.
作为我们在 5.3.1 节中了解到,一些编程语言处理数值的方式与其他语言不同。当我们有非常大的数字时,这一点尤其明显,至少超过 32 位数字,但对于超过 64 位限制的数字更是如此。由于普遍存在的浮点精度问题以及这种格式设计的已知缺陷,在处理十进制数字时也会出现很多问题。
As we learned in section 5.3.1, some programming languages handle numeric values differently from others. This is particularly apparent when we have very large numbers, at least above 32-bit numbers, but even more so with numbers past the 64-bit limit. It also comes up quite a lot when dealing with decimal numbers due to issues with floating point precision that are ubiquitous and a known drawback of the design of this format.
但最终,我们需要将数值发送给 API 用户(并接受来自这些用户的传入数值)。如果我们打算依赖序列化库而不深入挖掘,那么我们可能会非常失望。
Ultimately though, we need to send numeric values to the API users (and accept incoming numeric values from those same users). If we intend to rely on serialization libraries without digging any deeper, then it’s likely we’ll be pretty disappointed.
Listing 5.3 Two numbers are different but considered equal because they’re big
const compareJsonNumbers(): boolean { const a = JSON.parse('{"value": ➥ 9999999999999999999999999}'); ❶ const b = JSON.parse('{"value": ➥ 9999999999999999999999998}'); return a['value'] == b['value']; ❷ }
❶ These two number values are clearly not the same (they differ by 1).
❷然而,如果我们用它们解析一个 JSON 文档并进行比较,Node.JS 会说它们是一样的!
❷ However, if we parse a JSON document with them and compare, Node.JS will say they are the same!
这个问题不仅限于大整数。这也是十进制数的浮点运算的一个问题。
This problem isn’t only limited to large integer numbers. It also is a concern with floating point arithmetic for decimal numbers.
Listing 5.4 Adding numbers results in floating point arithmetic issues
const jsonAddition(): number { const a = JSON.parse('{"value": 0.1}'); const b = JSON.parse('{"value": 0.2}'); return a['value'] + b['value']; ❶ }
❶不幸的是,这会返回 0.30000000000000004 而不是 0.3。
❶ Unfortunately, this returns 0.30000000000000004 rather than 0.3.
那么我们该怎么办?清单 5.4 中的场景应该足以表明使用数值并假设它们在不同语言中都是相同的可能会非常可怕。简短的答案可能会让一些纯粹主义者感到沮丧,但它恰好工作得很好:使用一串字符。
So what do we do? The scenario in listing 5.4 should sufficiently suggest that using numeric values and assuming they’ll be the same across languages can be quite scary. The short answer might frustrate some purists, but it just happens to work pretty well: use a string of characters.
这个简单的策略,其中数值在序列化时表示为字符串值,只不过是一种避免将这些原始数值解释为实际数值的机制。相反,这些值本身可以由一个库来解释,该库可能会更好地处理这些类型的场景。换句话说,而不是将 0.2 解析为 JavaScriptNumber类型的 JSON 库,我们可以使用像 Decimal.js 这样的库将值解析为任意精度的十进制类型。
This simple strategy, where numeric values are represented as string values when serialized, is nothing more than a mechanism to avoid these primitive numeric values from being interpreted as actual numeric values. Instead, the values themselves can be interpreted by a library that might do a better job at handling these types of scenarios. In other words, rather than a JSON library parsing 0.2 as a JavaScript Number type, we can use a library like Decimal.js to parse the value into an arbitrary-precision decimal type.
Listing 5.5 Adding numbers correctly to avoid any floating point issues
const Decimal = require('decimal.js'); const jsonDecimalAddition(): number { const a = JSON.parse('{"value": "0.1"}'); ❶ const b = JSON.parse('{"value": "0.2"}'); return Decimal(a['value']).add(Decimal(b['value']); ❷ }
❶ Note that these numeric values are strings and not actual numbers.
❷当我们使用像 Decimal.js 这样的任意精度库添加这些时,我们得到正确的值 (0.3)。
❷ When we add these using an arbitrary-precision library like Decimal.js, we get the right value (0.3).
由于该策略的基础依赖于字符串,让我们花点时间探索一下细绳领域。
Since the underpinning of this strategy relies on strings, let’s take a moment to explore string fields.
在几乎每一种编程语言,字符串往往是我们认为理所当然的东西,而没有真正理解它们在幕后是如何工作的。即使是我们当中那些花时间学习 C 如何处理字符串的人也倾向于躲避字符编码和 Unicode 的广阔世界。虽然不一定要成为 Unicode、字符编码或其他与字符串相关主题的专家,但在考虑如何处理 API 中的字符串时,有几件事非常重要。由于 API 中的大多数字段往往是字符串,因此这一点尤为重要。
In almost every programming language, strings tend to be something we take for granted without truly understanding how they work under the hood. Even those of us who spent time learning how C handles strings of characters tend to hide from character encodings and the wide world of Unicode. And while it’s not imperative to become an expert on Unicode, character encoding, or other string-related topics, there are a few things that are pretty important when considering how to handle strings in an API. And since the majority of fields in an API tend to be strings, this is all the more important.
在西方世界,将字符串视为代表文本内容的单个字符的集合是非常安全的。这是一个相当大的概括,但这不是一本关于 Unicode 的书,所以我们必须概括一下。由于我们想要在 API 中传达的大量内容本质上是文本的,因此字符串可能是所有可用数据类型中最有用的。
In the Western world it’s pretty safe to think of strings as a collection of individual characters representing textual content. This is a pretty big generalization, but this isn’t a book on Unicode, so we’ll have to generalize. Since a great deal of the content we want to convey in an API is textual in nature, strings are probably the most useful of all the data types available.
字符串也可能是构建 API 时可用的最通用的数据类型。他们可以处理简单的字段,如姓名、地址和摘要;他们可以负责长文本块;并且,在序列化格式不支持通过线路发送原始字节的情况下,它们可以表示编码的二进制数据(例如,Base64 编码的字节)。字符串也是存储唯一标识符的最佳选择,即使这些标识符恰好看起来像数字而不是文本。我们可以在底层将它们存储为字节(更多信息请参见第 6 章),但 API 中的表示几乎肯定最好使用字符串来表示。
Strings are also probably the most versatile data type available when building an API. They can handle simple fields like names, addresses, and summaries; they can be responsible for long blocks of text; and, in cases where a serialization format doesn’t support sending raw bytes over the wire, they can represent encoded binary data (e.g., Base64 encoded bytes). Strings are also the best suited option for storing unique identifiers, even if those identifiers happen to look like numbers rather than text. We might store them as bytes under the hood (see chapter 6 for more information), but the representation in the API would almost certainly be best represented using a string of characters.
在我们开始讨论字符串如何成为世界的救世主之前,让我们花点时间看看字符串字段的一些潜在陷阱以及我们如何最有效地使用它们,从边界条件开始。
Before we start going on about how strings are the savior of the world, let’s take a moment to look at a few of the potential pitfalls with string fields and how we can use them most effectively, starting with boundary conditions.
作为我们在 5.3.1 节中了解到,边界条件很重要,因为最终我们必须考虑分配多少空间来存储数据。就像数值一样,我们在考虑字符串值时也有相同的方面。如果您曾经为关系数据库定义过模式并以 结束VARCHAR(128),其中 128 是一个完全任意的选择,您应该熟悉这种有时不受欢迎的必要性。
As we learned in section 5.3.1, boundary conditions are important because ultimately we have to consider how much space to allocate to store the data. And just like with numerical values, we have the same aspect to consider with string values. If you’ve ever defined a schema for a relational database and ended up with VARCHAR(128), where the 128 was a completely arbitrary choice, you should be familiar with this sometimes unwelcome necessity.
正如我们从数字中了解到的那样,这些大小限制很重要,因为数据接收端的那些人需要知道要分配多少空间来存储这些值。就像数字一样,在 API 生命周期的早期由于低估而增加大小是一种非常不舒服和不幸的情况。因此,在选择字符串字段的最大长度。
Just as we learned with numbers, these size limits matter because those on the receiving end of the data need to have an idea of how much space to allocate to store these values. And just like with numbers, increasing sizes due to underestimation early in the life of an API is a pretty uncomfortable and unfortunate situation to be in. As a result, it’s generally best to err on the side of rounding up when it comes to choosing the maximum lengths of string fields.
下一个值得解决的问题是如何定义最大长度。事实证明,我们最初将字符串定义为字符集合仅适用于某些有限的情况,因为许多语言并没有像我们希望的那样严格遵循这个概念。然而,更大的问题出现了,因为存储空间的度量单位(磁盘上的字节数)与字符串长度的度量单位(我们称之为字符)不保持一对一的关系。我们可以做什么?
The next question worth addressing though is how to define maximum lengths. It turns out that our original definition of a string as a collection of characters only works in some limited circumstances, because many languages don’t quite follow this concept as closely as we’d like. However, the bigger problem arises because the unit of measurement for storage space (bytes on disk) doesn’t maintain a one-to-one relationship with the unit of measurement for string length (what we’ve called characters). What can we do?
为了避免在 Unicode 上写一整章,对这个问题最简单的回答是继续从字符的角度思考(即使这些实际上是 Unicode代码点) 然后假设最详细的序列化格式用于存储目的:UTF-32。这意味着当我们存储字符串数据时,我们为每个字符分配 4 个字节,而不是我们在使用 ASCII 时可能期望的典型单字节。
With the aim of avoiding writing an entire chapter on Unicode, the simplest answer to this question is to continue thinking in terms of characters (even though these are actually Unicode code points) and then assume the most verbose serialization format for storage purposes: UTF-32. This means that when we store the string data, we allocate 4 bytes per character rather than the typical single byte we might expect when using ASCII.
不管这个存储空间难题如何,对于每个字符串字段我们都需要考虑另一个方面:处理过多的输入。对于数值,API 可以安全地拒绝超出范围的数字,并显示一条友好的错误消息:“请选择 0 到 10 之间的值。” 对于字符串值,我们实际上有两种不同的选择。我们总是可以拒绝这个值,就像拒绝一个超出范围的数字一样,但是,根据情况和上下文,这可能有点不必要。或者,如果文本超出定义的限制,我们可以选择截断文本。
Regardless of this storage space conundrum, there’s one other aspect we’ll need to consider for every string field: handing excessive input. With numerical values, a number out of range can safely be rejected by the API with a friendly error message: “Please choose a value between 0 and 10.” With string values, we actually have two different choices. We can always reject the value just like a number being out of range, but, depending on the circumstances and context, this might be a bit unnecessary. Alternatively, we have the option to truncate the text if it extends past the limit defined.
虽然截断似乎是个好主意,但它可能会产生误导和令人惊讶,正如我们在第 1 章中探讨的那样,这两者都不是好的 API 的特征。它还为 API 引入了一组新的选择(该字段是否应截断或拒绝?),这可能导致进一步的不可预测性,因为不同的领域表现出不同的行为。因此,就像数字一样,拒绝任何超出定义的限制的输入通常是最有意义的场地。
While truncation might seem like a good idea, it can be misleading and surprising, neither of which is a characteristic of a good API, as we explored in chapter 1. It also introduces a new set of choices for the API (Should this field truncate or reject?), which can lead to further unpredictability as different fields exhibit different behaviors. As a result, just like numbers, it generally makes the most sense to reject any input that extends past the defined limit of the field.
相似地对于数字和布尔字段,许多序列化格式不一定允许与零值 ( null) 不同的空值 ( "")。因此,很难确定用户指定字符串应为空字符串与用户明确要求 API 在给定其余上下文的情况下为该字段“做最好的事情”之间的区别。
Similarly to number and Boolean fields, many serialization formats don’t necessarily permit a distinct null value (null) from a zero value (""). As a result, it can be difficult to determine the difference between a user specifying that a string should be the empty string rather than a user specifically asking the API to “do what’s best” for the field given the rest of the context.
幸运的是,有很多选项可用。在许多情况下,空字符串根本不是字段的有效值。结果,空字符串确实可以用作指示应注入和保存默认值的标志。在其他情况下,字符串值可能具有一组特定的适当值,空字符串是其中一个选择。在这种情况下,允许选择"default"来充当指示应存储默认值的标志是完全合理的反而。
Luckily though, there are quite a few options available. In many cases, an empty string is simply not a valid value for a field. As a result, the empty string can indeed be used as a flag indicating that a default value should be injected and saved. In other cases, the string value might have a specific set of appropriate values, with the empty string being one of the choices. In this scenario, it’s perfectly reasonable to allow a choice of "default" to act as a flag indicating that a default value should be stored instead.
谢谢由于 Unicode 标准无处不在,几乎所有序列化框架和语言都以几乎相同的方式处理字符串。这意味着,与我们在 5.3 节中探讨的数值不同,我们的重点不是精度或细微的溢出错误,而是更多地关注安全处理可能跨越整个人类语言范围的字符串,而不仅仅是西方世界使用的字符.
Thanks to the ubiquity of the Unicode standard, almost all serialization frameworks and languages handle strings in pretty much the same way. This means that, unlike with the numerical values we explored in section 5.3, our focus is less on precision or subtle overflow errors and more on safely handling strings that might span the entire spectrum of human language rather than just the characters used in the Western world.
在幕后,字符串只不过是一大块字节。然而,我们解释这些字节的方式(编码)告诉我们如何将它们变成看起来像实际文本的东西——无论我们碰巧使用什么语言。简而言之,这意味着当需要序列化字符串时我们在 API 服务器上使用,我们不能只是以我们当时碰巧使用的任何编码将其发回。相反,我们应该标准化单一编码格式,并在所有请求和响应中一致地使用它。
Under the hood, strings are nothing more than a chunk of bytes. However, the way we interpret these bytes (the encoding) tells us how to turn them into something that looks like actual text—in whatever language we happen to be working in. Put simply, this means that when it comes time to serialize the string we’re using on the API server, we can’t just send it back in whatever encoding we happen to be using at the time. Instead, we should standardize on a single encoding format and use that consistently across all requests and responses.
虽然有很多编码格式(例如,ASCII、UTF-8 [ https://tools.ietf.org/html/rfc3629]、UTF-16 [ https://tools.ietf.org/html/rfc2781 ]、等),世界已经被 UTF-8 所吸引,因为它对于大多数常见字符来说非常紧凑,同时仍然足够灵活以编码所有可能的 Unicode 代码点。大多数面向字符串的序列化格式(例如 JSON 和 XML;https://www.w3.org/TR/xml/)都采用 UTF-8,而其他格式(例如 YAML)没有明确说明必须使用哪种编码使用。简而言之,除非有充分的理由不这样做,否则 API 应该对所有字符串内容使用 UTF-8 编码。
While there are lots of encoding formats (e.g., ASCII, UTF-8 [https://tools.ietf .org/html/rfc3629], UTF-16 [https://tools.ietf.org/html/rfc2781], etc.), the world has gravitated toward UTF-8, as it’s quite compact for most common characters while still flexible enough to encode all possible Unicode code points. Most string-oriented serialization formats (e.g., JSON and XML; https://www.w3.org/TR/xml/) have settled on UTF-8, while others (e.g., YAML) don’t explicitly note which encoding must be used. Put simply, unless there’s a great reason not to, APIs should use UTF-8 encoding for all string content.
如果您认为这是一个我们可以简单地让图书馆完成工作的地方,那么您几乎是对的,但不完全正确。事实证明,即使我们指定了一种编码,由于 Unicode 的规范化形式,也有多种方式来表示相同的字符串内容( https://unicode.org/reports/tr15/#Norm_Forms )。将其想象成我们可以表示数字 4 的各种方式:4、1+3、2+2、2*2、8/2 等等。在 UTF-8 编码的字符串中,可以对二进制表示做同样的事情。例如,字符“è”可以表示为单个特殊字符(“è”或0x00e9)或基本字符(“e”或0x0065)和重音字符(“`”或0x0301)的组合。这两种表示在视觉上和语义上是相同的,但它们在磁盘上不是由相同的字节表示的,因此,对于进行精确匹配的计算机来说,是完全不同的值。
If you’re thinking that this is one place where we can simply let the library do the work, you’re almost right, but not quite. It turns out that even after we specify an encoding, there are multiple ways to represent the same string content due to Unicode’s normalization forms (https://unicode.org/reports/tr15/#Norm_Forms). Think of this a bit like the various ways we can represent the number 4: 4, 1+3, 2+2, 2*2, 8/2, and so on. In a UTF-8 encoded string, it’s possible to do the same thing with the binary representations. For example, the character “è” can be represented as that single special character (“è” or 0x00e9) or as a combination of a base character (“e” or 0x0065) and an accent character (“`” or 0x0301). These two representations are visually and semantically identical, but they aren’t represented by the same bytes on disk and, therefore, to a computer that does exact matching, completely different values.
为了解决这个问题,Unicode ( http://www.unicode.org/versions/Unicode13.0.0/) 支持不同的规范化形式和一些在特定形式上标准化的序列化格式(例如 XML)(在 XML 的情况下,规范化形式 C)以避免混合和匹配这些语义相同的文本表示。虽然这对于表示 API 资源的长格式描述的字符串来说可能无关紧要,但当字符串表示标识符时,它就变得格外重要。如果标识符可以有不同的字节表示,那么相同的语义标识符实际上可能指向不同的资源。因此,对于 API 来说,拒绝不是使用规范化形式 C 编码的 UTF-8 的传入字符串是个好主意,但对于碰巧代表资源标识符的字符串来说,这是绝对必要的。有关标识符及其格式的更多信息,请查看在第 6 章。
To get around this, Unicode (http://www.unicode.org/versions/Unicode13.0.0/) supports different normalization forms and some serialization formats (e.g., XML) standardized on a specific form (in the case of XML, Normalization Form C) to avoid mixing and matching these semantically identical representations of text. While this might not matter all that much for a string representing a long-form description of an API resource, it becomes extraordinarily important when the string represents an identifier. If identifiers can have different byte representations, it’s possible to have the same semantic identifier actually refer to different resources. As a result, it’s a good idea for APIs to reject incoming strings that aren’t UTF-8 encoded using Normalization Form C, but an absolute necessity for strings that happen to represent resource identifiers. For more on this topic of identifiers and their formats, look at chapter 6.
枚举,有点像程序员的下拉选择器,是强类型编程语言世界的主要内容。虽然这些在 Java 等语言中可能是美妙的东西,但将它们移植到 Web API 世界中往往是一个错误。
Enumerations, sort of like drop-down selectors for programmers, are a staple in the world of strongly typed programming languages. And while these might be wonderful things in languages like Java, their transplantation into the world of web APIs is often a mistake.
虽然枚举可能非常有价值,因为它们都充当一种验证形式(只有指定的值被认为是有效的)和压缩形式(每个值通常由数字表示,而不是我们在代码中引用的文本表示),当就 Web API 而言,这两件事通常都是以降低灵活性和清晰度为代价的好处。
While enumerations can be very valuable, as they both act as a form of validation (only the specified values are considered valid) and compression (each value is typically represented by a number rather than the textual representation that we refer to in code), when it comes to a web API these two things are generally benefits with a cost of reduced flexibility and clarity.
Listing 5.6 Enumerations as types in an API
enum Color { Brown = 1, ❶ Blue, Green, ❷ } interface Person { id: string; name: string; eyeColor: Color; ❸ }
❶ Here we can define a bunch of possible colors.
❷ This list might go on to include many more options to be added later.
❸ We can use this enumeration to handle a person’s eye color.
例如,让我们考虑清单 5.6 中的枚举。如果我们将它与真正的整数值一起使用,我们可能最终会person.eyeColor被设置为2. person.eyeColor显然,这比设置为更令人困惑,"blue"因为前者需要我查找数字值的实际含义。在代码中,这通常根本不是问题;但是,在查看请求日志时,它会变得非常繁琐。
For example, let’s consider the enumeration in listing 5.6. If we were to use this with the true integer values, we might end up with person.eyeColor being set to 2. Obviously this is quite a bit more confusing than person.eyeColor being set to "blue" as the former requires me to look up what the number value actually means. In code this is usually not an issue at all; however, when looking through logs of requests it can become quite burdensome.
此外,当在服务器上添加新的枚举值时,客户端将需要更新他们自己的本地映射副本(通常需要更新客户端库),而不是简单地发送一个不同的值。更可怕的是,如果一个 API 决定添加一个新的枚举值,而客户端没有被告知该值的含义,客户端代码将感到困惑,不确定该怎么做。
Further, when new enumeration values are added on the server, the client will be required to update their own copy of that local mapping (often requiring a client library update) rather than simply sending a different value. Even scarier, if an API decides to add a new enumeration value and the client hasn’t been told what that value means, the client code will be left confused and unsure what to do.
例如,考虑 API 添加新Color值的场景,例如Hazel(#4)。除非更新客户端代码以适应这个新值,否则我们可能会遇到一个非常混乱的场景。另一方面,如果我们使用不同类型的字段(例如字符串),我们可能会由于先前未知的值而导致类似的错误,但我们不会被该值的含义所迷惑("hazel"is比4) 清楚得多。
For example, consider the scenario where an API adds a new Color value such as Hazel (#4). Unless the client-side code has been updated to accommodate this new value, we might end up with a pretty confusing scenario. On the other hand, if we use a different type of field (such as a string), we might end up with a similar error due to a previously unknown value, but we won’t be confused by the meaning of that value ("hazel" is much clearer than 4).
简而言之,当另一种类型(例如字符串)可以替代时,通常应避免使用枚举。当预计将添加新值时尤其如此,当存在某种针对所讨论值的标准时更是如此。例如,与其对有效文件类型(PDF、Word 文档等)使用枚举,不如使用允许特定媒体类型(以前称为 MIME 类型)的字符串字段更安全,例如"application/pdf"或"application/msword"。
In short, enumerations should generally be avoided when another type (such as a string) can work instead. This is particularly true when new values are expected to be added, and even more so when there’s some sort of standard out there for the value in question. For example, rather than using an enumeration for the valid file types (PDF, Word document, etc.), it’d be much safer to use a string field that allows specific media types (formerly known as MIME types) such as "application/pdf" or "application/msword".
现在在我们掌握了各种原始数据类型之后,我们可以开始深入研究这些数据类型的集合,其中最简单的是列表或数组。简而言之,列表无非是一些其他数据类型的集合,例如字符串、数字、映射(见 5.7 节),甚至其他列表。这些集合通常可以使用方括号表示法(例如, )通过索引items[2]或列表中的位置进行寻址,并且几乎所有序列化格式(例如,JSON)都支持。
Now that we have a grasp on the various primitive data types, we can start digging into collections of those data types, the simplest of these being a list or an array. In short, lists are nothing more than a group of some other data types, such as strings, numbers, maps (see section 5.7), or even other lists. These collections are typically addressable by the index or position in the list using square bracket notation (e.g., items[2]) and are supported by almost all serialization formats (e.g., JSON).
虽然并非所有存储系统本身都支持项目列表,但请记住,API 的目标是为远程用户提供最有用的接口,而不是公开存储在数据库中的确切数据。也就是说,列表是当今 Web API 中最常被误用的结构之一。那么什么时候应该使用列表呢?
While not all storage systems support lists of items natively, remember that the goal of an API is to provide the interface that’s most useful to a remote user, not to expose the exact data as it’s stored in a database. That said, lists are one of the more commonly misused structures seen in web APIs today. So when should you use a list?
通常,列表非常适合表示 API 资源固有内容的简单原始集合。例如,如果您有一个Book资源,您可能想要显示有关该书的类别或标签列表,最好将其表示为字符串列表。
In general, lists are great for simple primitive collections that represent something inherent to an API resource. For example, if you have a Book resource, you may want to show a list of categories or tags about the book, which might be best represented as a list of strings.
Listing 5.7 Storing a list of categories on a Book resource
interface Book { id: string; title: string; categories: string[]; ❶ }
❶ Here, books have a list of string categories attached to them.
隐在这个例子中有一点非常重要,需要记住:列表字段,尽管包含多个项目,但最好在项目被认为是要修改和替换的原子数据而不是零碎的数据时使用。换句话说,如果我们想更新Book资源的类别,我们应该通过替换整个项目列表来实现。换句话说,永远不应该有更新列表字段中第二项的方法。这有很多很好的理由,例如当我们按项目在列表中的位置来定位时,顺序变得非常重要,或者我们可能无法保证同时没有插入新项目,推项目感兴趣到一个新的位置。
Hidden in this example is something pretty important to remember: list fields, despite containing multiple items, are best used when the items are considered an atomic piece of data to be modified and replaced entirely rather than piecemeal. In other words, if we want to update the categories on a Book resource, we should do so by replacing the list of items in its entirety. Put differently, there should never be a way of updating the second item in a list field. There are many excellent reasons for this, such as the fact that order becomes extraordinarily important when we address items by their position in a list or that we might not have a guarantee that a new item wasn’t inserted in the meantime, pushing the item of interest into a new position.
此外,就像资源上的任何其他字段一样,允许从两个不同的地方存储和修改数据几乎总是一个坏主意,因此允许使用多种方法更新列表字段中的内容是值得避免的。这是特别诱人的,因为我们受过训练以依赖关系数据库的规范化原则。例如,如果我们使用类似 MySQL 的东西来存储Book资源上的这个类别列表,我们实际上可能有一个单独的表BookCategories具有唯一标识符、书籍的外键和具有唯一性约束的类别字符串。有一种诱惑是通过公开一个 API 来允许这种类别编辑,通过提供一本书的唯一 ID 和我们要编辑的类别来更新这些图书类别(因为从技术上讲,这足以唯一标识有问题的行)。
Further, just like any other field on a resource, it’s almost always a bad idea to allow data to be stored and modified from two different places, so allowing multiple methods of updating the content in a list field is something worth avoiding. This is particularly tempting because of the normalization principles we are trained to rely on with relational databases. For example, if we use something like MySQL to store this list of categories on a Book resource, we might actually have a separate table of BookCategories with a unique identifier, a foreign key to a book, and a category string with a uniqueness constraint. There is a temptation to allow this editing of categories by exposing an API to update these book categories by providing a unique ID of a book and the category we want to edit (since technically that is enough to uniquely identify the row in question).
允许这种修改会打开一个非常可怕的与一致性相关的蠕虫病毒:有人可能会设置类别列表,而其他人正在使用不同的入口点编辑单个类别。在这种情况下,即使依赖其他事务隔离机制(例如,ETags;https://tools.ietf.org/html/rfc7232#section-2.3)也不会有多大用处,从而产生一个 API,即,简而言之,充满惊喜。
Allowing this sort of modification opens a very scary can of worms related to consistency: it’s possible that someone might set a list of categories while someone else is editing a single category using a different entry point. In that case, even relying on other mechanisms for transaction isolation (e.g., ETags; https://tools.ietf.org/html/ rfc7232#section-2.3) won’t be of much use, resulting in an API that is, put simply, full of surprises.
列表值的一个好的经验法则是几乎将它们视为列表实际上是一个 JSON 编码的项目字符串。换句话说,你不是在设置book.categories = ["history", "science"],而是更接近于book.categories = "[\"history\", \"science\"]"。您永远不会期望 API 允许您修改字符串中的单个字符,所以不要期望 API 允许您修改列表字段中的单个条目。
A good rule of thumb for list values is to treat them almost as though the list was actually a JSON-encoded string of items. In other words, you’re not setting book.categories = ["history", "science"] but instead something closer to book.categories = "[\"history\", \"science\"]". You wouldn’t ever expect an API to allow you to modify a single character in a string, so don’t expect an API to allow you to modify a single entry in a list field.
有关这些资源主题及其相互关系的更多探索,请查看第 4 部分,尤其是第 13、14 和 15 章,所有这些内容都涉及使用列表字段表示 Web API 中的关系数据的想法。
For more exploration on these topics of resources and their relationships to one another, look at part 4, particularly chapters 13, 14, and 15, all of which touch on the idea of using list fields to represent relational data in web APIs.
接下来要考虑的是列表是否应该允许在同一个列表中使用不同的数据类型。换句话说,将字符串值与同一列表中的数字值混合匹配是个好主意吗?虽然这样做肯定不是一场彻底的灾难,但它可能会导致一些混乱,特别是考虑到第 5.3.3 节中关于处理大数和小数的指导。这些值可能表示为字符串,因此很难弄清给定条目实际上是字符串还是仅表示为字符串的数值。基于此,通常最好坚持单一数据类型并保留列表值同质。
The next thing to consider is whether a list should permit different data types in the same list. In other words, is it a good idea to mix and match string values with number values in the same list? While it certainly isn’t a total disaster to do so, it can lead to some confusion, particularly given the guidance from section 5.3.3 on handling large numbers and decimal numbers. These values might be represented as strings, so it could become difficult to untangle whether a given entry is actually a string or a numeric value that was only represented as a string. Based on this, it’s generally best to stick to a single data type and keep list values homogeneous.
最后,列表值的一个非常常见的场景是关于大小:当列表变得太长以至于无法管理时会发生什么?为了避免这种令人沮丧的情况,所有列表都应该有一些限制规定它们最多可能有多少项目以及每个项目可能有多大(请参阅第 5.3.1 或 5.4.1 节了解如何处理数字和字符串的边界数据类型)。此外,超出这些边界的输入应该被拒绝而不是被截断(出于与第 5.4.1 节中解释的相同原因)。这有助于避免因值列表过大而意外增长到难以处理的大小而引起的意外和混乱。
Finally, one very common scenario with list values is about size: what happens when the list gets so long that it’s unmanageable? To avoid this frustrating scenario, all lists should have some bound imposes specifying how many items they might have at most as well as how large each item may be (see section 5.3.1 or 5.4.1 for how to handle bounds for numeric and string data types). Additionally, inputs beyond these boundaries should be rejected rather than truncated (for the same reasons explained in section 5.4.1). This helps avoid surprise and confusion coming from exceedingly large lists of values that may accidentally grow to an unwieldy size.
如果列表的潜在大小难以估计,或者有充分的理由怀疑它可能会在没有硬边界的情况下增长,那么依赖实际资源的子集合而不是资源上的内联列表可能是个好主意。虽然它可能看起来很麻烦,但在未来由于无限列表字段而资源不是很大时,API 将更易于管理。然而,如果你最终陷入困境,请查看分页模式(第 21 章),它可以帮助将大而笨重的资源变成更小的可管理资源块。
If the potential size of a list is difficult to estimate or there’s a good reason to suspect it may grow without a hard boundary, it’s probably a good idea to rely on a subcollection of actual resources rather than an in-line list on the resource. While it might seem cumbersome, the API will be much more manageable in the future when resources aren’t enormous due to unbounded list fields. However, if you end up backed into a corner with this, look at the pagination pattern (chapter 21), which can help turn large, unwieldy resources into smaller manageable chunks.
列表类似于字符串,因为在某些序列化格式和库中,没有简单的方法来区zero分值(对于列表,[])和一个null值。然而,与字符串不同的是,将空列表作为值几乎总是合理的,这使得 API 不太可能依赖空列表值作为指示请求使用某个默认值而不是空列表的方式。这给我们留下了一个复杂的问题:在这种情况下,您如何指定您希望值是默认值而不是字面上的空项目列表?
Lists are similar to strings in that in some serialization formats and libraries there is no easy way to distinguish between the zero value (for lists, []) and a null value. Unlike strings, however, it’s almost always reasonable to have an empty list as a value, making it unlikely that an API could rely on an empty list value as a way of indicating a request to use some default value instead of the empty list. This leaves us with a complicated question: how do you specify in this scenario that you want the value to be whatever the default is rather than a literal empty list of items?
不幸的是,在这种情况下似乎没有任何简单优雅的答案。可以在创建时使用默认值(假设空列表对于新创建的资源无效)或者列表值根本不是此信息的正确数据类型。如果前一种情况由于某种原因不起作用,唯一的其他安全选择是完全跳过列表值并切换到将此数据作为与父资源分开管理的适当资源子集合来管理。显然这不是一个理想的解决方案,但它肯定是给定的最安全的选择这约束。
Unfortunately, in situations like these there doesn’t seem to be any simple elegant answer. Either the default can be used at creation (with the assumption that an empty list would not be valid for a newly created resource) or a list value is simply not the right data type for this information. If the former scenario doesn’t work for one reason or another, the only other safe option is to skip the list value entirely and switch over to managing this data as a proper subcollection of resources that are managed separately from the parent resource. Obviously this is not an ideal solution, but it’s certainly the safest choice available given the constraints.
最后,我们可以讨论我们可用的最通用和最有趣的数据类型:地图。在本节中,我们实际上将考虑两种相似但不同的基于键值的数据类型:自定义数据类型(我们一直称之为资源或接口) 和动态键值映射(通常 JSON 称为对象或映射)。虽然这两者并不相同,但它们之间的差异是有限的,主要区别在于是否存在预定义模式。换句话说,地图是键值对的任意集合,而自定义数据类型或子资源可能以相同的方式表示;但是,它们有一组预定义的键以及对应值的特定类型。让我们首先看看那些具有预定义模式的。
Finally, we can discuss the most versatile and interesting data type available to us: maps. In this section, we’ll actually consider two similar but distinct key value–based data types: custom data types (what we’ve been calling resources or interfaces) and dynamic key-value maps (often what JSON calls objects or maps). While these two are not identical, the differences between them are limited, with the main distinction being the existence of a predefined schema. In other words, maps are arbitrary collections of key-value pairs, whereas custom data types or sub-resources might be represented in the same way; however, they have a predefined set of keys as well as specific types for the corresponding values. Let’s start by looking at those with a predefined schema.
随着资源的发展以表示越来越多的信息,一个典型的步骤是将相似的信息片段组合在一起,而不是让所有内容保持扁平。例如,当我们添加越来越多关于ChatRoom资源的配置时,我们可能会决定将其中一些配置字段组合在一起。
As resources evolve to represent more and more information, a typical step is to group similar pieces of information together rather than keeping everything flat. For example, as we add more and more configuration about a ChatRoom resource, we might decide instead to group some of these configuration fields together.
Listing 5.8 Storing data directly on a resource or in a separate structure
interface ChatRoomFlat { id: string; name: string; password: string; ❶ requirePassword: boolean; ❶ allowAnonymousUsers: boolean; ❶ allowChatBots: boolean; ❶ } interface ChatRoomReGrouped { id: string; name: string; securityConfig: SecurityConfig; ❷ } interface SecurityConfig { ❷ password: string; requirePassword: boolean; allowAnonymousUsers: boolean; allowChatBots: boolean; }
❶ In the flattened option, all fields are stored directly on the resource.
❷在这里,我们将与访问 ChatRoom 相关的所有信息提取到一个单独的结构中。
❷ Here we pull out all the information related to accessing the ChatRoom in a separate structure.
在这种情况下,我们只是根据控制安全和聊天室访问的共同主题将几个字段组合在一起。这种抽象级别之所以有意义,是因为我们真正考虑的是对资源的相似字段进行分组。另一方面,如果字段与资源相关,但在资源上下文之外具有根本的区别和意义,则可能值得探索单例子资源,如第 12 章所述。
In this case, we’ve simply grouped several fields together based on their common theme of controlling security and access to a chat room. This level of abstraction makes sense only because we’re really thinking in terms of grouping similar fields about a resource. If, on the other hand, the fields are related to the resource but fundamentally distinct and meaningful outside the context of the resource, it might be worthwhile to explore the singleton sub-resource, explained in chapter 12.
现在我们已经探索了自定义数据类型及其预定义模式,让我们花点时间看看动态键值映射。虽然它们最终可能以相同的方式呈现,但这两种结构通常以截然不同的方式使用。虽然我们使用自定义数据类型作为将相似字段折叠在一起并将它们隔离在单个字段中的方法,但动态键值映射更适合存储没有预期结构的任意动态数据。换句话说,自定义数据类型只是一种重新排列我们已知并希望组织得更好的字段的方法,而映射更适合在我们定义 API 时具有未知键的动态键值对。此外,虽然跨资源的这些密钥可能存在一些重叠,
Now that we’ve explored custom data types with their predefined schemas, let’s take a moment to look at dynamic key-value maps. While they may end up rendered in the same way, these two structures are often used in very different ways. Whereas we used a custom data type as a way of collapsing similar fields together and isolating them inside a single field, dynamic key-value maps are a better fit for storing arbitrary, dynamic data that has no expected structure. In other words, custom data types are simply a way of rearranging fields we know of and want to organize a bit better, while maps are better suited for dynamic key-value pairs with keys that are unknown at the time we define the API. Further, while there may be some overlap in these keys across resources, it’s absolutely not a requirement that all resources have the same keys as they would with a custom data type field.
这种键值对的任意结构非常适合依赖于特定资源实例细节的动态设置或配置。要了解这意味着什么,假设我们有一个 API 用于管理杂货店商品的产品信息。我们显然需要一种方式来说明每种产品中含有哪些成分以及含量,例如 3 克糖、5 克蛋白质、7 毫克钙等。使用列出所有可能成分的预定义模式来执行此操作将非常困难,即使我们能够做到这一点,这些项目的许多值也将为零,因为每个项目都有各种不同的成分。在这种情况下,地图可能最有意义。
This kind of arbitrary structure of key-value pairs is a great fit for things like dynamic settings or configuration that depends on the details of a particular instance of a resource. To see what this means, imagine that we have an API for managing product information for items in a grocery store. We would obviously need a way of saying what ingredients were in each product and in what quantities, for example 3 grams of sugar, 5 grams of protein, 7 milligrams of calcium, and so on. It would be quite difficult to do this with a predefined schema listing all the possible ingredients, and even if we were able to do that, many of the values for these items would be zero since each item has all sorts of different ingredients. In this case, a map might make the most sense.
Listing 5.9 Tracking ingredients and amounts using a map field
interface GroceryItem { id: string; name: string; calories: number; ingredientAmounts: Map<string, string>; ❶ }
❶ We can rely on a map of ingredients to the amounts.
使用定义的模式,a 的 JSON 表示GroceryItem可能类似于清单 5.10。在这种情况下,成分是动态的,并且可以根据不同的项目自由变化米。
Using the schema defined, the JSON representation of a GroceryItem might look something like listing 5.10. In this case, the ingredients are dynamic and free to vary for each different item.
Listing 5.10 Example JSON representation of an ingredients map
{ "id": "514a0119-bc3f-4e3f-9a64-8ad48600c5d8", "name": "Pringles", "calories": "150", "ingredientAmounts": { "fat": "9 g", "sodium": "150 mg", "carbohydrate": "15 g", "sugar": "1 g", "fiber": "1 g", "protein": "1 g" } }
重要的是要注意,由于我们可以为地图的键选择数据类型,从技术上讲,我们可以自由选择阳光下的任何东西。键类型的某些选择将是灾难性的(例如,某些不容易序列化的丰富数据类型)。其他人可能看起来可以接受,但仍然是一个坏主意。例如,从技术上讲,允许数字值作为地图的键类型可能是有意义的;然而,正如我们在 5.3.3 节中了解到的,数字会遇到一些非常危险的问题,尤其是当它们变大时,但有时甚至只是小数值。因此,字符串几乎肯定是映射中键数据类型的最佳选择。
It’s important to note that since we can choose the data type for the keys of our map, technically we’re free to choose anything under the sun. Some choices for a key type would be disastrous (e.g., some rich data type that is not easily serializable). Others might look like they’re acceptable but are still a bad idea. For example, technically it might make sense to allow a number value to be the key type for a map; however, as we learned in section 5.3.3, numbers suffer from some pretty dangerous issues, particularly when they get big, but even sometimes just as small decimal values. As a result, strings are almost certainly the best choice for the data type of the keys in a map.
更重要的是,由于映射键在技术上是唯一标识符,因此将这些字符串值以 UTF-8 格式编码也很重要,并且正如我们在 5.4.3 节中了解到的(并将在第 6 章中了解更多)这些字符串在规范化形式 C 中以避免由于字节表示问题导致的重复键值。
More importantly, since map keys are technically unique identifiers, it’s also important that these string values be encoded in the UTF-8 format, and, as we learned in section 5.4.3 (and will learn more about in chapter 6) that these strings be in Normalization Form C to avoid duplicate key values due to byte representation issues.
尽管自定义数据类型的模式避免了任何边界问题(相反,模式定义了自己的边界条件),映射与列表非常相似,因为它们都可以很容易地失去控制。因此,映射,就像列表一样,应该定义字段中可能包含多少键和值的上限。这可能变得非常重要,因为它设定了对该领域内容可能增长的预期。除此之外,对每个键和每个值可以作为字符串值的大小设置限制也很重要(有关字符串值边界的更多信息,请参阅第 5.4.1 节)。通常,这会指定一个键可能最多 100 个左右的字符,而值可能最多 500 个左右的字符。
While custom data types’ schemas avoid any bounding problems (instead, the schema defines its own boundary conditions), maps are very similar to lists in that they can both very easily grow out of control. As a result, maps, just like with lists, should define an upper bound on how many keys and values might be included in the field. This can become pretty important as it sets expectations on how large the field’s content might grow. Beyond that, it’s also important to set limits on how large each key and each value can be as string values (see section 5.4.1 for more information on string value bounding). In general this works out to specifying that a key might be up to 100 or so characters and values might be up to 500 or so characters.
在极少数情况下,值的大小可能分布不均匀。因此,一些 API 选择提供存储在 map 字段中的字符总数的上限,允许用户自由决定如何表示这些不同的字符(一些非常小的值和一两个非常大的值)。虽然如果绝对必要,这是一个可以接受的策略,但应该避免,因为它往往会导致滑坡,即越来越多的数据最终被存储在地图中,而地图可能存储得更好别处。
On rare occasions, values’ sizes might not be as evenly distributed. As a result, some APIs have opted to provide an upper bound on the total number of characters to be stored in the map field, allowing users the freedom to decide how those different characters might be represented (a few very small values and one or two very large values). While this is an acceptable strategy if absolutely necessary, it should be avoided as it tends to lead toward a slippery slope where more and more data ends up being stored in a map when it probably is better stored elsewhere.
不像列表,在几乎所有序列化格式和语言中,很容易区分空映射值或零值 ( {}) 和空值 ( null)。在这种情况下,让该null值指示 API 应该根据 API 请求的其余部分执行它认为最适合该字段的任何操作是非常安全的。另一方面,空地图是用户指定地图不应包含任何数据的方式,这本身就是一个有意义的陈述。
Unlike lists, in almost all serialization formats and languages it’s pretty easy to distinguish between an empty map value or zero value ({}) and a null value (null). In that case, it’s pretty safe to let the null value indicate that the API should do whatever it thinks is best for the field given the rest of the API request. An empty map, on the other hand, is a user’s way of specifying that the map should contain no data at all, which is itself a meaningful statement.
一种日本的一家针对日语使用者的公司只想在其 API 中的字符串字段中使用 UTF-16 编码而不是 UTF-8。在做出这个决定之前,他们应该考虑什么?
A company in Japan targeting Japanese speakers only wants to use UTF-16 encoding rather than UTF-8 for the string fields in their API. What should they consider before making this decision?
想象一下,ChatRoom资源当前归档任何早于 24 小时的消息。如果您想提供一种方法来禁用此行为,您应该如何命名将执行此操作的字段?这是提供这种定制的最佳设计吗?
Imagine that ChatRoom resources currently archive any messages older than 24 hours. If you want to provide a way to disable this behavior, what should you name the field that would do this? Is this the best design to provide this customization?
如果您的 API 是用原生支持任意大数字的语言编写的(例如 Python 3),那么将它们作为字符串序列化为 JSON 仍然是最佳实践吗?或者您可以依赖标准的 JSON 数字序列化吗?
If your API is written in a language that natively supports arbitrarily large numbers (e.g., Python 3), is it still best practice to serialize these to JSON as strings? Or can you rely on the standard JSON numeric serialization?
如果你想代表聊天室的本地语言,是否可以接受支持语言的枚举值?如果是这样,这个枚举会是什么样子?如果不是,哪种数据类型最好?
If you want to represent the native language for a chat room, would it be acceptable to have an enumeration value for the supported languages? If so, what would this enumeration look like? If not, what data type is best?
If a specific map has a very uneven distribution of value sizes, what’s the best way to place appropriate size limits on the various keys and values?
For every value, we also need to consider both a null value and an undefined or missing value, which may or may not have the same meaning.
布尔值最适合用于标志,并且应该这样命名,使得真值意味着积极的方面(例如,enableFeature而不是disableFeature)。
Booleans are best used for flags and should be named such that a true value means the positive aspect (e.g., enableFeature rather than disableFeature).
Numeric values should have a true numeric meaning rather than just being made up of numeric digits.
To avoid issues with large numbers (above 32 or 64 bits) or floating point arithmetic issues, numeric values should be serialized as strings in languages that don’t have appropriate native representations.
Strings should be UTF-8 encoded, and for any string used as an identifier of any sort, normalized to Normalization Form C.
Enumerations should generally be avoided, relying on string values instead, with validation done on the server side rather than the client side.
Lists should be treated as atomic collections items that are individually addressable on the client side only.
Both lists and maps should be bounded by the total number of items allowed in the collection; however, maps should further bound the sizes of both keys and values.
在下面的一组设计模式中,我们不会着眼于解决特定的、范围狭窄的问题,而是探索适用于几乎所有 API 的更广泛的主题。这些核心设计模式将为设计良好的 API 奠定基础,但我们也将依赖它们作为我们稍后将看到的设计模式的构建块。
In the following set of design patterns, rather than looking at addressing a specific, narrowly scoped problem, we’ll instead explore broader topics that are applicable to almost all APIs. These core design patterns will set the stage for designing good APIs, but we’ll also rely on them as building blocks for the design patterns we’ll see later on.
在第 6 章中,我们将从研究如何唯一标识 API 中的资源开始。然后,在第 7 章到第 9 章中,我们将使用标准和自定义方法定义一组核心交互模式(以及学习在对资源子集进行操作时如何应用这些模式)。在第 10 章中,我们将学习如何处理可能需要很长时间才能使用 LRO 完成的 API 方法,最后,第 11 章将介绍如何使用可重新运行的作业管理 API 中的重复工作。
In chapter 6, we’ll start by looking at how the resources in our APIs can be uniquely identified. Then, in chapters 7 through 9, we’ll define a core set of interaction patterns with standard and custom methods (as well as learning how these apply when operating on subsets of a resource). In chapter 10, we’ll learn how to handle API methods that might take a long time to complete with LROs and, finally, chapter 11 will cover how to manage repeated work in APIs with rerunnable jobs.
在本章中,我们将深入探讨资源标识符。这包括它们是什么、什么是好的(和坏的)以及如何在您的 API 中使用它们。我们还将深入研究当今使用的一些常见标识符格式(例如通用唯一标识符或 UUID;https://tools.ietf.org/html/rfc4122),以及专门用于 Web API 的新自定义格式。
In this chapter, we’ll explore resource identifiers in-depth. This includes what they are, what makes for a good one (and a bad one), as well as how they can be used in your APIs. We’ll also dig in to some of the common identifier formats in use today (such as universally unique identifiers or UUIDs; https://tools.ietf.org/html/ rfc4122), as well as new custom formats targeted specifically for use in web APIs.
第一的,当我们谈论资源标识符时,我们究竟指的是什么?简而言之,标识符为我们提供了一种在 API 中唯一寻址和讨论单个资源的方法。用更专业的术语来说,这些标识符是字节块(通常是字符串值或整数),我们可以将其用作指向资源集合中的一个资源的方式。几乎总是,这些标识符使 API 的用户能够执行某种类型的查找操作。换句话说,这些标识符用于在一些更大的资源集合中寻址单个资源。
First, what exactly do we mean when we talk about resource identifiers? In short, identifiers give us a way to uniquely address and talk about individual resources in an API. In more technical terms, these identifiers are chunks of bytes (usually a string value or an integer number) that we can use as the way we point to exactly one resource in a resource collection. Almost always, these identifiers enable users of an API to perform some type of lookup operation. In other words, these identifiers are used to address a single resource among some larger collection of resources.
事实证明,我们在日常生活中经常使用这样的标识符;然而,并不是所有的标识符都同样有用(甚至同样唯一)。例如,许多政府使用姓名和出生日期作为一个人的标识符,但在很多情况下,人们会因为没有两个人共享姓名和出生日期这一假设而被混淆。至少在美国,更好的选择是社会安全号码(政府分配的九位数字),用于在大多数金融交易(例如纳税或贷款)中唯一识别一个人, 但这个数字有其自身的一系列缺点。例如,SSN 目前限制在总共 10 亿人(九位数)以内,因此随着美国人口逐年增长,
It turns out that we often use identifiers like these in our everyday lives; however, not all identifiers are equally useful (or even equally unique). For example, many governments use a name and date of birth as an identifier for a person, but there have been plenty of cases where people are mixed up based on the assumption that no two individuals share a name and birth date. A better option, in the United States at least, is a Social Security number (a government-assigned nine-digit number), which is used to uniquely identify a person for most financial transactions (e.g., paying taxes or taking out a loan), but this number has its own set of drawbacks. For instance, SSNs are currently limited to a maximum of 1 billion people in total (nine digits), so as the population of the US grows over the years, the country may run out of numbers to give out.
在 API 中拥有资源标识符显然很重要;然而,这些标识符到底应该是什么样子就不太清楚了。换句话说,我们没有必要说什么属性使一些标识符好而另一些不好。在下一节中,我们将更多地探讨这些细节。
It’s clearly important to have identifiers for resources in an API; however, it’s much less clear what exactly these identifiers should look like. Put differently, we haven’t necessarily said what attributes make some identifiers good and others bad. In the next section, we’ll explore these in more detail.
自从我们需要在 API 中交互的几乎所有资源都需要标识符,很明显标识符很重要。由于由我们作为 API 设计者来选择这些标识符的表示形式,这就留下了一个大问题:什么使标识符更好?要回答这个问题,我们需要探索标识符的许多不同属性以及我们需要在其中做出决定的各种选项。目前,我们将只关注属性本身,稍后会提出更具体的建议(在第 6.3 节中)。
Since almost all resources we’ll need to interact with in our API will need identifiers, it’s clear that identifiers are important. And since it’s up to us as API designers to choose the representation of these identifiers, this leaves one big question: what makes an identifier good? To answer this, we’ll need to explore many different attributes of identifiers and the various options we’ll need to decide between. For now, we’ll focus exclusively on the attributes themselves and make more specific recommendations a bit later (in section 6.3).
这最简单的起点也是最明显的起点之一:标识符应该易于在最常见的场景中使用。也许标识符最典型的场景是在 Web API 中查找单个资源(我们在 1.4 节中作为标准 get 方法学习的内容)。这意味着向服务器发送请求“请给我资源 <ID>”应该尽可能简单明了,同时尽量减少出错的机会。特别是,这必须考虑到标识符经常出现在 URI 中这一事实。这意味着,例如,"/"在标识符中使用正斜杠 ( ) 或其他保留字符会有点棘手,因为它们在 URI 中具有特殊含义,可能应该是避免了。
The simplest place to start is one of the most obvious: identifiers should be easy to use in the most common scenarios. Perhaps the most typical scenario for an identifier is looking up an individual resource in a web API (what we learned as the standard get method in section 1.4). This means that sending a request to a server asking “Please give me resource <ID>” should be as simple and straightforward as possible while minimizing the opportunity for mistakes. In particular, this must take into consideration the fact that identifiers will often show up in URIs. This means that, for example, using a forward slash ("/") or other reserved characters in an identifier is going to be a bit tricky as they have special meaning in URIs and should probably be avoided.
其他一个好的标识符的明显要求可能是它必须是真正唯一的。换句话说,根据定义,标识符必须是完全独一无二的,否则它就不能真正完成其工作,即唯一标识 API 中的单个资源。这似乎有点过于简单化,但我们需要解决一些微妙的问题,以了解真正独一无二的含义。其中之一是唯一性很少是绝对的,而是通常取决于考虑标识符的上下文或范围。例如,在电脑公司范围内,Apple被认为是唯一标识(电脑公司范围内只有一个Apple),但在所有公司范围内,Apple实际上并不是唯一的(还有Apple Records,Apple Bank , ETC。)。我们需要决定标识符是否需要在同一类型的所有资源、同一 API 中的所有资源或世界上的所有资源中保持唯一。诚然,真正的全局唯一性在理论上是不可能的,但在实践中应该是可行的,只要有足够大的密钥空间(标识符的可能选择)并且没有故意的错误演员。
Another obvious requirement of a good identifier is probably that it must be truly unique. In other words, an identifier must be, by definition, completely one of a kind or it isn’t really capable of doing its job, which is to uniquely identify a single resource in an API. This might seem a bit overly simplistic, but there are some subtleties we need to address about what it means to be truly unique. One of these is that uniqueness is rarely absolute and instead usually depends on the context or scope in which the identifier is considered. For example, in the scope of computer companies, Apple is considered a unique identifier (there’s only one Apple in the scope of computer companies), but in the scope of all companies Apple isn’t actually unique (there’s also Apple Records, Apple Bank, etc.). We’ll need to decide whether identifiers need to be unique across all resources of the same type, all resources in the same API, or all resources in the world. Admittedly, true global uniqueness is theoretically impossible but should be doable in practice given sufficiently large key spaces (the possible choices for identifiers) and no intentional bad actors.
下一个,更微妙的是,标识符一旦分配就不应更改。这主要是因为在标识符最终会发生变化的情况下引入了潜在的问题(因此需要做出进一步的决定)。例如,想象一下Book(id=1234, name="Becoming")更改其标识符并变为Book(id=5678, name="Becoming"). 这本身可能没什么大不了的,但请考虑创建一本新书:Book(id=1234, name="Design Patterns")。在这种情况下,这本新书重用了原始标识符 ( 1234),这最终意味着,根据您请求 的时间,Book 1234您会得到不同的结果。
Next, and slightly more subtle, is the idea that identifiers should probably not change once they’re assigned. This is primarily because of the potential problems introduced (and therefore further decisions to be made) in scenarios where identifiers do end up changing. For example, imagine the case where Book(id=1234, name="Becoming") changes its identifier and becomes Book(id=5678, name="Becoming"). This on its own might not be a big deal, but consider then that a new book is created: Book(id=1234, name="Design Patterns"). In this case, this new book reuses the original identifier (1234), which ultimately means that depending on when you ask for Book 1234, you would get different results.
对于大多数情况,这可能没什么大不了的,但想象一下,用户可以指定他们最喜欢的书,然后有人选择Book 1234成为他们最喜欢的书 ( User (favoriteBook=1234, ...))。这就留下了一个非常合理的问题:他们最喜欢的书是Becoming还是Design Patterns?在这个标识符不是永久性的世界里,要回答我们指的是哪本书的问题,我们还需要知道最喜欢的书是什么时候被选中的,或者更具体地说,它是什么时候被检索到的Book 1234。没有这些信息,就不清楚我们实际上在谈论哪本书。因此,最好不仅标识符是永久性的,而且它们是一次性的,永远的。换句话说,一旦Book 1234存在,您永远不应该再次使用相同的标识符,即使是在原始标识符之后Book 1234是删除。
For most scenarios this might not be a big deal, but imagine that users can specify their favorite book and someone selects Book 1234 to be their favorite (User (favoriteBook=1234, ...)). This leaves a very reasonable question: is their favorite book Becoming or Design Patterns? In this world where identifiers aren’t permanent, to answer the question of which book we’re referring to we also need to know when the favorite was selected, or more specifically when it retrieved Book 1234. Without that information, it’s unclear what book we’re actually talking about. As a result, it’s best not only that identifiers are permanent, but that they’re single-use, forever. In other words, once Book 1234 exists, you should never end up using that same identifier ever again, even after the original Book 1234 is deleted.
所以到目前为止,我们一直在使用像“1234”这样的简单数字作为示例标识符,但这些不太可能是现实生活中的实际标识符。相反,这些标识符更有可能具有一些随机性元素,而不是依赖于递增的整数。由于这通常意味着在选择这些标识符时需要进行更多计算,因此确保做出选择不会花费太长时间很重要。
So far, we’ve been using simple numbers like “1234” as example identifiers, but it’s unlikely these will be actual identifiers in real life. Instead, it’s far more likely that these identifiers will have some element of randomness to them rather than relying on incrementing integers. Since that typically means there’s more computation in choosing these identifiers, it’s important to ensure that it doesn’t take too long to make that choice.
例如,由于我们说过所有标识符必须是永久的并且永远是一次性的,这意味着我们需要确定我们选择的任何 ID 都没有被使用过。为此,我们可以保留所有资源的列表并随机选择 ID,直到找到以前未使用过的 ID,但这既不是最有效的,也不是最简单的选择。随着时间的推移,它也可能变得越来越慢,使其成为一个更糟糕的选择。无论我们选择什么选项,它都应该快速、简单,而且最重要的是,具有可预测的性能特征。
For example, since we said that all identifiers must be permanent and single-use forever, that means we’ll need to be certain that any ID we choose hasn’t been used already. We could do this by keeping a list of all the resources and choosing IDs at random until we find one that hasn’t been used before, but that’s not exactly the most efficient nor the simplest option out there. It’s also likely to get slower and slower over time, making it an even worse choice. Whatever option we choose, it should be fast and easy, and, most importantly, have predictable performance characteristics.
尽管重要的是标识符在被选择时快速且容易(并且可以预见),同样重要的是人们很难预测下一个标识符是什么。最终这归结为安全问题,并且可以通过足够大的密钥空间轻松解决,但如果选择这些标识符的方法与选择下一个可用数字一样简单,则可以更轻松地定位和利用潜在漏洞,例如配置错误的安全规则.
While it’s important that identifiers are fast and easy (and predictably so) when being chosen, it’s also important that it’s difficult for someone to predict what the next identifier will be. Ultimately this comes down to security and can be easily solved with a sufficiently large key space, but if the method of choosing those identifiers is as simple as choosing the next available number, it makes it easier to target and exploit potential vulnerabilities like misconfigured security rules.
例如,如果攻击者只是在随机检查随机分配的 256 位整数密钥空间中意外未受保护的资源,则攻击到甚至存在的资源的可能性非常小,更不用说配置错误的资源了。另一方面,如果我们使用一种更可预测的方法,例如在相同的密钥空间(1、2、3、...)上计算整数,那么几乎可以保证攻击者找到存在的资源并希望登陆一个错误配置为向世界开放而不是锁定的吃下。
For example, if an attacker were just probing around randomly checking for resources that were accidentally unprotected in a randomly allocated key space of 256-bit integers, there’s a very small chance of landing on a resource that even exists, let alone one that’s misconfigured. If, on the other hand, we use a far more predictable method, such as counting integers over the same key space (1, 2, 3, . . .), then an attacker is almost guaranteed to find resources that exist and can hope to land on one that’s misconfigured to be open to the world rather than locked down.
下一个,虽然我们作为工程师可能不愿承认这一点,但我们在 API 中使用的标识符在某些时候可能需要通过电话进行交流、粘贴到短信中或通过其他一些非计算媒体共享。这意味着,例如,我们不希望混淆数字 1、小写 L、大写 I 或竖线字符 ( |),因为将这些写在纸上可能很棘手(尝试l1Il|lI1I1lIl1I1l1lI通过电话交流“”,即使在代码字体中也很棘手!)。简而言之,我们应该考虑到标识符将由人类解释和交流,因此在这方面不要给它们带来不必要的困难很重要。
Next, while we as engineers may hate to admit it, the identifiers we use in our APIs may at some point need to be communicated over a phone, pasted into a text message, or shared over some other noncomputational medium. This means, for example, that we don’t want confusion between the digit 1, the lowercase L, uppercase I, or the pipe character (|), as writing these down on paper can be tricky (try communicating "l1Il|lI1I1lIl1I1l1lI" over the phone, which is tricky even in code font!). In short, we should consider that identifiers will be interpreted and communicated by humans, so it’s important that they aren’t needlessly difficult in this aspect.
除了易于交流之外,一个好的标识符还可以轻松快速地确定标识符本身是否被复制错误。换句话说,我们应该能够区分什么都不指向任何东西的有效标识符和一个永远不会指向任何东西的完全无效的标识符。提供此功能的一种简单方法是使用标识符的简单校验和段。这不是一个疯狂的想法,而且经常被使用。例如,用于书籍的标识符称为 ISBN ( https://en.wikipedia.org/wiki/International_Standard_Book_Number ),它们的工作方式就是这样,书籍 ISBN 的最后一位数字用作校验位以验证其他数字实际上有意义并且没有被打错了。
In addition to easy communication, a good identifier makes it easy to quickly determine if the identifier itself has been copied wrong. In other words, we should be able to tell the difference between a valid identifier that points to nothing and a completely invalid identifier that will never point to anything. One easy way to provide this is to use a simple checksum segment of the identifier. This isn’t such a crazy idea and is used quite often. For example, the identifiers used for books are called ISBNs (https://en.wikipedia.org/wiki/International_Standard_Book_Number) and they work in just this way, where the last digit of a book’s ISBN is used as a check digit to verify that the other digits actually make sense and haven’t been mistyped.
最后,标识符将一直被使用,因此大小和空间效率将变得很重要。这意味着我们应该渴望将尽可能多的信息打包成尽可能短的值。这可能意味着选择密度更高的字符集,例如 Base64 ( https://tools.ietf.org/html/rfc4648 ),而不是密度较低的字符集,例如简单数字。在此示例中,请考虑如果我们使用数字 ID,则每个字符总共只能存储 10 个选择,但如果我们使用 Base64 编码的文本,则每个字符可以存储 64 个。
Finally, identifiers are going to be used all the time, so size and space efficiency are going to be important. This means we should aspire to pack as much information into as short a value as possible. This might mean choosing a more dense character set such as Base64 (https://tools.ietf.org/html/rfc4648) over a less dense one, such as simple numerals. In this example, consider that we can only store a total of 10 choices per character if we use numeric IDs, but we can store 64 per character if we use Base64-encoded text.
优化信息密度将是与构成良好标识符的其他属性的平衡行为。例如,Base64 有许多可用字符,但这些字符包括小写“i”、大写“I”和数字“1”,我们了解到这些字符在写下或阅读时可能会造成混淆。为了了解所有这些是如何结合在一起的,让我们看看我们可能如何选择格式的各种设计考虑因素这些身份标识。
Optimizing for information density will be a balancing act with the other attributes that make good identifiers. For example, Base64 has many characters available, but those characters include both lowercase “i,” uppercase “I,” and the digit “1,” which we learned can be confusing when written down or read. To see how all of this comes together, let’s look at the various design considerations for how we might go about choosing a format for these identifiers.
现在如果我们对在一个好的标识符中寻找哪些属性有一个不错的想法,我们可以尝试实现一个用于定义资源标识符的方案。首先,我们将浏览建议的实现,然后将其与第 6.2 节中描述的各种标准进行比较。
Now that we have a decent idea of what attributes to look for in a good identifier, we can try implementing a scheme to use for defining resource identifiers. First, we’ll walk through the suggested implementation, and after that we’ll compare it to the various criteria described in section 6.2.
前我们可以深入了解其他许多内容,我们需要首先确定用于存储这些标识符的数据类型。虽然有很多合理的选项,但到目前为止,字符串是最通用的,因此是推荐的选项。它们提供最高的信息密度(通过 HTTP 使用的每个字符 7 位熵),使用起来很熟悉,并且在决定如何使它们易于共享、阅读和复制时提供了很大的回旋余地。
Before we can get into much else, we’ll need to first settle on the data type to use for storing these identifiers. While there are lots of reasonable options, strings, by far, are the most versatile and therefore the recommended option. They provide the highest information density (7 bits of entropy per character used over HTTP), are familiar to use, and provide lots of wiggle room in deciding how to make them easy to share, read, and copy.
虽然还有其他几个选项,从常见的(例如,正整数)到不太常见的(原始未解释的字节),这些提供了由它们的类型强加的人为限制(例如,整数被限制为每个 ASCII 字符 10 个选择,或者HTTP 上每个 ASCII 字符约 3.3 位熵)或其他缺点(例如,原始字节在许多编程语言中使用起来不方便)。
While there are several other options, ranging from the common (e.g., positive integers) to the not so common (raw uninterpreted bytes), these provide artificial limitations imposed by their type (e.g., integers are limited to 10 choices per ASCII character, or ~3.3 bits of entropy per ASCII character over HTTP) or other drawbacks (e.g., raw bytes are inconvenient to work with in many programming languages).
由于我们已经确定了字符串,所以我们有一组全新的问题需要回答。首先,这些字符串的字符集是什么?我们可以使用任何有效的 Unicode 字符串吗?或者有限制吗?之后,我们必须决定是否允许所有字符。例如,在 HTTP 中,正斜杠字符 ( "/") 通常用于表示分隔符,因此我们可能不希望在标识符中使用它。此外,正如我们之前了解到的,某些字符(如竖线字符"|")很容易与其他字符(如"I"、"1"或"l")混淆。我们怎么办这些?让我们从看角色开始放。
Since we’ve settled on strings, we have a whole new set of questions to answer. First, what is the character set of these strings? Can we use any valid Unicode string? Or are there limitations? After that, we have to decide whether all characters are allowed. For example, in HTTP, the forward slash character ("/") is generally used to denote a separator, so we probably wouldn’t want that in our identifier. Further, as we learned earlier, some characters (like the pipe character, "|") are easily confused with others (like "I", "1", or "l"). What do we do about these? Let’s start by looking at the character set.
作为尽管我很想回忆和解释 Unicode 及其所有内部工作原理,但这不仅会花费太长时间,而且我怀疑我对该标准的了解仍然存在差距。因此,我将重点关注一些涉及字符集的简单示例,以阐明此实现将依赖 ASCII(美国信息交换标准代码)的原因。
As much as I’d love to reminisce and explain Unicode and all its inner workings, not only would it take far too long, but I suspect there are still gaps in my knowledge of the standard. As a result, I’ll focus on some simple examples involving character sets to clarify why this implementation will rely on ASCII (American Standard Code for Information Interchange).
ASCII 非常简单:7 位数据代表 128 个不同的字符。这些数据通常被压缩到一个 8 位字节中,HTTP 请求在当今与大多数 Web API 交互时使用它。正如您可能猜到的那样,有远远超过 128 个不同的字符,这正是 Unicode 规范旨在解决的问题。这很棒; 然而,在最终向后兼容的使命中,Unicode 实际上允许不止一种方式来表示我们认为相同的文本块。例如,“á”可以表示为单个 Unicode 代码点 ( U+00E1) 或“a” 和重音字符代码点 (U+0061和U+0301). 虽然它们在我们的屏幕(或纸上)上看起来都像“á”,但当存储在数据库中时,它们看起来非常不同——这正是问题所在,因为计算机看不到视觉上的“á”字符;他们看到组成它的字节。
ASCII is pretty simple: 7 bits of data to represent 128 different characters. This data is typically shoved into an 8-bit byte, which is used by HTTP requests when interacting with the majority of web APIs today. As you might guess, there are far more than 128 different characters, which is what the Unicode specification aims to solve. This is great; however, in the mission for ultimate backward compatibility, Unicode actually allows for more than one way to represent what we would consider the same chunk of text. For example, “á” could be represented as a single Unicode code point (U+00E1) or a composition of the “a” and the accent character code points (U+0061 and U+0301). While these both look like “á” on our screens (or on paper), when stored in a database they look very different—which is exactly the problem since computers don’t see the visual “á” character; they see the bytes that make it up.
虽然肯定有办法解决这个问题(例如,使用 Normalization Form C 和其他 Unicode 魔法),但歧义导致的问题远远超过它们的价值(例如,您现在必须指定如何处理提交的数据,但这些数据不在规范化表格 C 等)。因此,现代 Web API 最安全的选择是依赖 ASCII 作为唯一标识符。
While there are certainly ways to get around this (e.g., use Normalization Form C and other Unicode wizardry), the ambiguities cause far more problems than they’re worth (e.g., you must now specify how to handle data submitted that isn’t in Normalization Form C, etc.). As a result, the safest bet for modern web APIs is to rely on ASCII for unique identifiers.
现在我们已经确定了 ASCII,我们需要决定是否进一步将其限制为 128 个字符的子集,因为其他原因担忧。
Now that we’ve settled on ASCII, we’ll need to decide whether we further limit that to a subset of the 128 characters due to other concerns.
尽管如果我们可以使用 ASCII 提供的所有 128 个字符(或者甚至是 Unicode 标准可以表示的所有字符串),那就太好了,我们需要考虑一个重要且理想的标识符属性,我们在第 6.2.6 节中讨论过:ease阅读、分享和复制。这意味着,很简单,我们需要避免使用容易与其他字符混淆或可能混淆 HTTP 请求中的 URL 的 ASCII 字符。这使我们找到了一种非常有用的序列化格式:Crockford 的 Base32 ( https://www.crockford.com/base32.html )。
While it would be lovely if we could use all 128 characters provided by ASCII (or even all strings that the Unicode standard can represent), there’s one important and desirable attribute of identifiers we need to consider, which we discussed in section 6.2.6: ease of reading, sharing, and copying. This means, quite simply, that we need to avoid characters in ASCII that are easily confused with others or might obfuscate the URL in the HTTP request. This leads us to a very useful serialization format: Crockford’s Base32 (https://www.crockford.com/base32.html).
Crockford 的 Base32 编码依赖于使用 32 个可用的 ASCII 字符,而不是全部 128 个字符。这包括 A 到 Z 和 0 到 9,但遗漏了许多特殊字符(如正斜杠)以及I、L、O和U。虽然前三个由于潜在的混淆而被遗漏(I并且l可能与 混淆1,并且O可能与 混淆0),但由于另一个有趣的原因而遗漏了第三个:亵渎。事实证明,通过省略U字符,您实际上不太可能最终在标识符中出现英语亵渎。
Crockford’s Base32 encoding relies on using 32 of the available ASCII characters rather than all 128. This includes A through Z and 0 through 9 but leaves out lots of the special characters (like the forward slash) as well as I, L, O, and U. While the first three are left out due to potential confusion (I and l could be confused with 1, and O could be confused with 0), the third is left out for another interesting reason: profanity. It turns out that by leaving out the U character, it’s actually less likely that you’ll end up with an English-language profanity in your identifiers.
话虽如此,您可能已经注意到其他令人费解的事情:我们为什么要费心遗漏L?毕竟,大写形式不能与任何东西混淆;但是,它可能会与小写形式(与1)混淆。原因是这种格式有一个单一的规范形式,由大写字母(较少提及)和 0-9 组成,但在解码各种输入时更能容忍错误。在这种情况下,该算法不会简单地说这L是一个无效字符,而是会假设它被误认为是数字(以小写形式)1并以这种方式对待它。这种不区分大小写的应用更普遍(例如,a被视为A)并适用于其他可能混淆的字符,例如O和o(将被视为0)。换句话说,这种格式对错误非常友好,并且被设计成可以根据这些错误将继续发生的假设来做正确的事情。
With that said, you might have noticed something else puzzling: why did we bother to leave out L? After all, the uppercase form can’t be confused with anything; however, it could be confused in the lowercase form (with 1). The reason is that this format has a single canonical form, made up of the uppercase letters (less those mentioned) and 0–9, but it is more accommodating of mistakes when decoding a variety of inputs. In this case, rather than simply saying that L is an invalid character, this algorithm will assume that it was mistaken (in its lowercase form) for the digit 1 and treat it that way. This case insensitivity applies more generally (e.g., a is treated as A) and applies to other potentially confused characters such as O and o (which will be treated as 0). In other words, this format is exceedingly friendly toward mistakes and is designed so that it can do the right thing based on the assumption that those mistakes will continue to occur.
最后,Crockford 的 Base32 编码将连字符视为可选,这意味着如果需要,我们可以灵活地引入连字符以提高可读性。这意味着我们可以拥有 ID abcde-12345-ghjkm-67890,它将被规范地表示为ABCDE12345GHJKM67890Crockford 的 Base32。
Finally, Crockford’s Base32 encoding treats hyphens as optional, meaning we have the flexibility of introducing hyphens for readability purposes if we want. This means that we could have the ID abcde-12345-ghjkm-67890, which would be canonically represented as ABCDE12345GHJKM67890 in Crockford’s Base32.
简而言之,Crockford 的 Base32 编码满足 6.2 节中概述的大量标准:它易于使用,易于制作唯一,提供相当高的信息密度(32 种选择或每个 ASCII 字符 5 位),易于制作不可预测,而且,最重要的是,由于其适应性和灵活的解码过程,它具有很高的可读性、可复制性和可共享性。现在我们还有几个问题要解决地址。
In short, Crockford’s Base32 encoding satisfies quite a large number of the criteria outlined in section 6.2: it’s easy to use, easy to make unique, provides reasonably high information density (32 choices or 5 bits per ASCII character), easy to make unpredictable, and, most importantly, very readable, copyable, and shareable thanks to its accommodating and flexible decoding process. Now we just have a few more issues to address.
一6.2.6 节中列出的关键要求之一是能够区分丢失的标识符(例如,它从未创建或删除)和永远不可能存在的标识符,这意味着在标识符。一种简单的方法是将某种形式的完整性检查作为标识符的一部分。这种完整性检查通常采用标识符末尾的一些固定数量的校验和字符的形式,这些校验和字符是从标识符本身的其余内容中派生出来的。然后,在解析标识符时,我们可以简单地重新计算校验和字符并测试它们是否匹配。如果他们不这样做,那么标识符的某些部分已经损坏并且被认为是无效的。
One of the key requirements listed in section 6.2.6 is the ability to distinguish between an identifier that is missing (e.g., it was never created or was deleted) and one that could never possibly exist, which would mean that there was clearly a mistake in the identifier. One easy way to do this is by including some form of integrity check as part of the identifier. This integrity check typically takes the form of some fixed number of checksum characters at the end of the identifier that is derived from the rest of the content of the identifier itself. Then, when parsing the identifier, we can simply recalculate the checksum characters and test whether they match. If they don’t, then some part of the identifier has been corrupted and it’s considered invalid.
Crockford 的 Base32 包含一个基于模计算的简单算法。简而言之,我们把值当作一个整数,除以 37(大于 32 的最小素数),然后计算该值的余数。然后我们使用单个 Base37 数字(规范包括 5 个附加字符作为校验和数字)来表示余数价值。
Crockford’s Base32 has a simple algorithm included based on a modulo calculation. In short, we treat the value as an integer, divide it by 37 (the smallest prime number greater than 32), and calculate the remainder of the value. Then we use a single Base37 digit (and the specification includes 5 additional characters for checksum digits) to denote the remainder value.
尽管我们主要关注标识符的唯一部分,能够从标识符中了解资源的类型也非常有价值。换句话说,被告知资源的 IDabcde-12345-ghjkm-67890不是很有用,除非您还被告知所涉及的资源类型(例如,以便您可以决定调用什么 RPC 或访问 URL 以检索资源)。一种常见的解决方案是在 ID 前加上所涉及集合的名称,例如books/abcde-12345-ghjkm-67890. 通过这样做,我们可以获取该标识符,了解它所谈论的资源类型,并使用它做一些有用的事情。通过使用正斜杠作为分隔符,我们最终得到一个适合 HTTP 请求的完整标识符(例如,GET /books/abcde-12345-ghjkm-67890). 还有很多其他可用的选项(例如,使用冒号字符,生成类似 的 ID book:abcde-12345-ghjkm-67890);然而,考虑到与 HTTP 的友好关系以及在标准 URL 中的实用性,带有集合名称的正斜杠往往是一个很好的选择合身。
While we’ve focused primarily on the unique part of the identifier, it’s also very valuable to be able to know the type of the resource from its identifier. In other words, being told a resource has an ID of abcde-12345-ghjkm-67890 is not very useful unless you’re also told the type of resource involved (so that you can decide what RPC to call or URL to visit to retrieve the resource, for example). One common solution to this is to prefix the ID with the name of the collection involved, such as books/abcde-12345-ghjkm-67890. By doing this, we can take that identifier, know what type of resource it’s talking about, and do useful things with it. By using a forward slash as the separator, we end up with a full identifier that would fit in an HTTP request (e.g., GET /books/abcde-12345-ghjkm-67890). There are plenty of other options available (e.g., using a colon character, resulting in an ID like book:abcde-12345-ghjkm-67890); however, given the friendly relationship with HTTP and usefulness in standard URLs, a forward slash with the collection name tends to be a great fit.
现在我们已经决定将集合名称用作标识符的一部分(例如,books/1234而不仅仅是1234),这导致了一个新问题:层次结构如何在标识符中工作?例如,如果我们同时拥有Book资源和Page资源(页面属于书籍),我们是在标识符中表达这种层次关系,还是将每个资源都保留为自己的顶级资源?换句话说,Page资源是否有像pages/5678(顶级资源)或books/1234/pages/5678(表示标识符中的层次关系)这样的标识符?
Now that we’ve decided that we’ll use the collection name as part of the identifier (e.g., books/1234 rather than just 1234), this leads to a new question: how does hierarchy work in an identifier? For example, if we have both Book resources and Page resources (with pages belonging to books), do we express that hierarchical relationship in the identifier or do we leave each as their own top-level resource? In other words, would Page resources have an identifier like pages/5678 (top-level resources) or books/1234/pages/5678 (representing hierarchical relationships in the identifier)?
答案是层次关系非常有用,应该在适当的时候使用,但是在一组特定的场景中它们是有意义的(在许多情况下层次结构不是一个好的选择)。API 设计者常常依赖层次关系作为两种资源之间关联的表达,而不是所有权,这可能会产生问题。例如,我们可能会说,“Book 1 is currently on Shelf 1”,最终资源标识符看起来像shelves/1/books/1. 不幸的是,由于书籍往往会随着时间的推移在书架之间移动,我们需要更改标识符以反映这一移动。而且,正如您可能猜到的那样,这与我们在第 6.2.3 节中定义的永久性要求背道而驰。
The answer is that hierarchical relationships are very useful and should be used when appropriate, but there is a specific set of scenarios in which they make sense (and many where hierarchy is not a good choice). Too often, API designers rely on hierarchical relationships as an expression of association between two resources rather than ownership, which can become problematic. For example, we might say, “Book 1 is currently on Shelf 1” and end up with resource identifiers that look like shelves/1/books/1. Unfortunately, since books tend to move between shelves over time, we’ll want to change the identifier to reflect this move. And, as you might guess, this flies right in the face of the permanence requirements we defined in section 6.2.3.
虽然制定“书籍永远不能在书架之间移动”的规则可能是完全合理的(在这种情况下,这个标识符将是完全合理的),但这种行为限制并不是真正必要的。相反,我们应该简单地认识到一本书在书架上的当前位置是这本书本身的一个可变属性,并将其存储shelfId为资源的一个属性Book。
While it might be perfectly reasonable to make it a rule that “books shall never move between shelves” (in which case this identifier would be perfectly reasonable), this kind of behavioral restriction isn’t really necessary. Instead, we should simply recognize that a book’s current position on a shelf is a mutable attribute of the book itself and store the shelfId as a property of the Book resource.
到底什么时候使用分层标识符才有意义?一般来说,当 API 代表一种资源对另一种资源的真正所有权时,API 应该只依赖于层次关系,特别是关注关系的级联特征。这意味着当我们想要级联删除(如果删除父级,它会删除子级)或级联安全规则(在父级上设置安全规则,它们向下流向子级)时,我们应该将资源分层在其他资源之下。显然,正如我们之前提到的,资源不应该能够从一个父节点转移到另一个父节点。例如,虽然Books可能会在 之间移动Shelves,但Pages通常不会在 之间移动Books. 这意味着我们会将Page资源标识为books/1/pages/2而不仅仅是pages/2.
When exactly does it make sense to use hierarchical identifiers? In general, APIs should only rely on hierarchical relationships when they represent true ownership of one resource over another, specifically focused on the cascading characteristics of the relationship. This means that we should layer resources underneath other resources when we want things like cascading deletion (if you delete the parent, it deletes the children) or cascading security rules (set security rules on the parent and they flow downward to the children). And obviously, as we noted previously, resources should not be capable of moving from one parent to another. For example, while Books might move between Shelves, Pages typically won’t move between Books. This means that we’d identify a Page resource as books/1/pages/2 rather than just pages/2.
此外,在我们依赖分层标识符来表达所有权关系的情况下,重要的是要记住子资源(页面)仅存在于其父资源(书籍)的上下文中。这意味着我们永远不能单独谈论第 2 页,因为它会引出一个明显的问题:哪本书的第 2 页?实际上,这意味着页面的标识符始终包含父资源的标识符。
Additionally, in cases where we rely on hierarchical identifiers to express an ownership relationship, it’s important to remember that the child resource (the page) only ever exists within the context of its parent (the book). That means that we can never talk about page 2 alone because it leads to the obvious question: page 2 of what book? In practical terms, this means that the identifier of a page always includes the identifier of the parent resource.
这导致了一个稍微更微妙但仍然非常重要的问题:我们可以拥有多个 ID 均为“2”的页面吗?例如,我们可以同时拥有books/1/pages/2和books/9/pages/2吗?这个特殊的例子应该有希望使这个问题的答案显而易见。毕竟,很多书都有第二页。由于父资源不同,标识符是两者的层级关系组合,而这两个标识符相似(都以“pages/2”结尾),所以两个字符串完全不同,这两个页面属于到完全不同的书(在这个例子中,书 1 和书 9)。
This leads to a slightly more subtle question, but one that’s still pretty important: can we have multiple pages all with the ID “2”? For example, can we have both books/1/pages/2 and books/9/pages/2? This particular example should hopefully make the answer to this an obvious yes. After all, lots of books have a second page. Since the parent resources are different and the identifier is the combination of the two in a hierarchical relationship, while these two identifiers are similar (as they both end in “pages/2”), the two strings are completely different and these two pages belong to entirely different books (in this example, book 1 and book 9).
现在我们已经了解了一个好的标识符应该是什么样子,让我们看一下创建和使用符合以下标准的标识符所涉及的一些较低级别的技术细节这些期望。
Now that we have an idea of what a good identifier might look like, let’s look at some of the lower-level technical details involved in creating and working with identifiers that align with these expectations.
所以到目前为止,我们已经探索了标识符的特征和一些细节,但是还有很多更深层次的技术细节我们需要形式化,这是本节的重点。让我们先看看标识符的直截了当和基本的东西:它有多大。
So far we’ve explored characteristics of identifiers and some of the specifics, but there are quite a few of the deeper technical details we need to formalize, which is the focus of this section. Let’s start by looking at something straightforward and fundamental to an identifier: how big it is.
如何给定的标识符应该有多长?虽然我们可以简单地说它随时间变化(即,标识符的长度可能会随着创建的更多而增加),但对于使用可能将这些标识符存储在他们的数据库中的 API 的人来说,这会使事情变得相当复杂,所以它通常是为这些标识符提供一些空间需求的可预测性,并简单地选择一个固定的大小来确定它们的长度是个好主意。
How long should a given identifier be? While we could simply say it varies over time (i.e., the length of the identifier might grow as more are created), that can make things quite complicated for people using the API who might be storing these identifiers in their databases, so it’s generally a good idea to provide some predictability on the space requirements for these identifiers and simply choose a fixed size for how long they’ll be.
在这种情况下,根据我们的需要,我们有几个不同的选择。首先解决最简单的选项,我们可能只需要在单个资源类型中唯一的标识符。换句话说,同时拥有书 1 和作者 1 可能完全没问题,因为我们有不同的资源类型来区分这两个 1。在这种情况下,我们可能只需要大约 64 位的存储空间(类似于在 MySQL 等关系数据库中使用单个 64 位整数)。使用 Crockford 的 Base32,每个 ASCII 字符我们总共有 32 个选择(或 5 位),这意味着我们可能可以使用总共 12 个字符作为标识符(为我们的标识符提供 60 位)。当序列化(和反序列化)这个时,我们将需要一个字符作为校验和数字,总共 13 个字符,books/AHM6A83HENMP~(或者books/ahm6a83henmp~使用books/ahm6-a83h-enmp~小写和潜在的连字符作为分隔符以提高可读性)。
In this case, we have a few different options depending on our needs. Addressing the simplest option first, we may only need identifiers that are unique across a single resource type. In other words, it might be perfectly fine to have both book 1 and author 1, since we have the different resource types to distinguish between the two 1’s. In this case, we probably only need around 64 bits worth of storage space (similar to using a single 64-bit integer in a relational database like MySQL). Using Crockford’s Base32, we have a total of 32 choices (or 5 bits) per ASCII character, which means we can probably get away with 12 total characters for the identifier (providing 60 bits for our identifier). When serializing (and de-serializing) this, we will need one more character for the checksum digit, meaning 13 characters in total, leading to identifiers like books/AHM6A83HENMP~ (or books/ahm6a83henmp~ or books/ahm6-a83h-enmp~ using lowercase and potential hyphens as separators for readability).
在其他一些情况下,我们可能需要全局唯一的标识符——不仅跨资源类型,甚至跨单个 API,而且跨所有资源。为实现这一点,我们的目标可能应该是拥有至少与标准 UUID(通用唯一标识符)相同数量的可能标识符,这是一个由 32 个十六进制数字表示的 128 位 ID。由于其中 6 位保留用于有关 ID 的元数据,因此剩下 122 位用于标识符值。使用 Crockford 的 Base32,我们将需要大约 24 个字符,这将为我们提供一个 120 位的标识符。将其与一个额外的校验和数字相结合,总共 25 个字符,导致标识符可能看起来像books/64s36d-1n6rv-kge9g-c5h66~(请记住,连字符是可选的,技术上可以放在任何地方,并且所有字母都不区分大小写)。
In some other cases, we may need identifiers that are globally unique—not just across resource types or even across this single API, but across all resources everywhere. To accomplish this, we should probably aim to have at least the same number of possible identifiers as standard UUIDs (universally unique identifiers), which is a 128-bit ID represented by 32 hexadecimal digits. Since 6 of those bits are reserved for metadata about the ID, this leaves 122 bits for the identifier value. Using Crockford’s Base32, we would need around 24 characters, which would give us a 120-bit identifier. Combine this with one additional checksum digit for a total of 25 characters, leading to identifiers that might look like books/64s36d-1n6rv-kge9g-c5h66~ (remember, the hyphens are optional and can technically be placed anywhere, and all the letters are case insensitive).
稍后我们将在讨论别名时了解到,我们可能希望引入一个前导“0”字符来区分标识符和别名,这会在我们的常规 ID 中添加一个额外的字符。尽管如此,现在是时候讨论一个重要但微妙的(迄今为止假设的)问题:谁制造了身份证。
As we’ll learn later when we talk about aliases, we may want to introduce a leading “0” character to distinguish between identifiers and aliases, which would add one extra character to our regular IDs. With all that behind us though, it’s time to discuss an important but subtle (and thus far assumed) issue: who makes IDs.
我们已经谈了很多关于标识符应该采用的格式和数据类型;然而,一直有一种假设认为这些只是随机标识符,而我们并未说明如何实际创建它们。让我们先来看看这方面的一个重要方面:起源。
We’ve talked quite a bit about the format and data type that an identifier should take; however, there’s been a running assumption that these are just random identifiers, and we’ve said nothing about how to actually create them. Let’s start by looking at an important aspect of this: origin.
前在我们谈论如何生成这些标识符之前,我们应该首先谈论应该由谁来实际创建它们。简而言之,值得注意的是,允许 API 的用户选择他们自己的标识符是一件非常危险的事情,应该避免。
Before we talk about how to generate these identifiers, we should first talk about who should actually create them. In short, it’s worth noting that allowing users of an API to choose their own identifiers is a very dangerous thing and should be avoided.
对于初学者来说,当这个名字在过去的某个时候被使用过时,这可能会导致沮丧和困惑。由于我们的永久性要求(第 6.2.3 节)意味着标识符绝不能重复使用(即使在被删除之后),想要重新创建资源的用户可能会因为在确定资源时收到有关 ID 冲突的错误消息而感到困惑具有该 ID 的不存在(现在可能不存在,但过去某个时候存在)。虽然这可以通过足够大的可用 ID 集来缓解,但人类特别不擅长从给定的集合中选择随机值。
For starters, this can lead to frustration and confusion when the name has been used at some point in the past. Since our requirement of permanence (section 6.2.3) means identifiers must never be reused (even after being deleted), a user who wants to recreate a resource might be confused by getting an error message about an ID collision when they are certain a resource with that ID doesn’t exist (and it might not exist now, but it did at some point in the past). While this can be mitigated with a sufficiently large set of available IDs, humans are particularly bad at choosing random values from a given set.
接下来,当 API 用户想要选择自己的标识符时,他们往往会做出错误的决定,例如,将个人身份信息放入标识符本身。换句话说,某人可能会创建一个标识符为pad(base32Encode("harry-potter-8"))(相当于D1GQ4WKS5NR6YX3MCNS2TE05===), 显然, 作者想对世界保密。然而,这可能很困难,因为大多数系统不会将 ID(甚至是 Base32 编码的 ID)视为秘密信息。相反,标识符通常被视为不透明的随机数据片段,但使用起来最方便。这意味着它们通常被认为可以安全地存储在日志文件中并与任何人共享。这包括在办公室周围的大仪表板上显示这些 ID,突出显示单个资源的潜在错误或统计信息。如果 API 允许用户选择标识符,那么有一天,大客户(或政府)可能会要求对某些 ID 保密,这可能非常难以(甚至不可能)实现。
Next, when API users want to choose their own identifiers, they tend to make poor decisions, for example, by putting personally identifiable information into the identifier itself. In other words, someone might create a book with an identifier of pad(base32Encode("harry-potter-8")) (equivalent to D1GQ4WKS5NR6YX3MCNS2TE05===), which, obviously, the author would want to keep secret from the world. However, this can be difficult because most systems don’t treat IDs (even Base32-encoded ones) as secret information. Instead, identifiers are commonly treated as opaque, random pieces of data to be used however is most convenient. This means that they’re generally considered safe to store in log files and share with anyone. This includes showing these IDs on big dashboards around the office, highlighting potential errors or statistics for individual resources. If an API allows user-chosen identifiers, there may come a day where a large customer (or a government) demands that some IDs be kept secret, which may be exceptionally difficult (or even impossible) to accomplish.
最后,我们对标识符的最后要求之一是它们是不可预测的(参见第 6.2.5 节)。虽然您的 API 的用户肯定有密码学或伪随机数生成器方面的背景,但正如我们之前提到的,通常情况并非如此。因此,要求用户选择他们自己的标识符可能会出现问题。在最好的情况下,如果需要它可能会很烦人(“你为什么不能为我想出一个 ID?”),但在最坏的情况下它可能很危险,即使这是一个可选功能。特别是,这为用户提供了生成可预测标识符的机会,使他们成为安全的简单攻击目标错误。
Finally, one of our last requirements of identifiers is that they are unpredictable (see section 6.2.5). While it’s certainly possible that the users of your API will have a background in cryptography or pseudo-random number generators, as we noted before, that’s generally not the case. As a result, asking users to choose their own identifiers can be problematic. In the best case, it can be annoying if required (“Why can’t you just come up with an ID for me?”), but in the worst case it can be dangerous, even if this is an optional feature. In particular, this creates an opportunity for users to generate predictable identifiers, making them an easy attack target for security mistakes.
Pseudo-random number generators
只是与大多数编程问题一样,我们有几种不同的方法来生成随机标识符。一种选择是使用加密安全的随机字节生成器(例如,Node.js 的crypto.randomBytes()函数) 并将其转换为 Crockford 的 Base32(例如,使用 Node.js 的base32-encode包). 另一种选择是使用随机选择算法从 Crockford 的 Base32 字母表中选择一组字符。这些都不一定是对或错,只是不同而已。
Just like with most programming problems, we have several different ways to go about generating random identifiers. One option is to use a cryptographically secure random byte generator (e.g., Node.js’s crypto.randomBytes() function) and convert that into Crockford’s Base32 (using, for example, Node.js’s base32-encode package). Another option would be to use a random choice algorithm to choose a set number of characters from Crockford’s Base32 alphabet. Neither of these is necessarily right or wrong, just different.
前者关注随机选择的标识符所涉及的字节数(选择一个 ID,因为它将存储在数据库中),而后者更关心面向用户的标识符(选择一个 ID,因为用户将实际看到它) . 只要您提前计算,知道您希望字节代表多少个字符,或者您选择的字符需要在数据库中保留多少字节,任何一个都是合理的。
The former focuses on the number of bytes involved in the randomly chosen identifier (choosing an ID as it will be stored in the database) while the latter cares more about the user-facing identifier (choosing an ID as a user will actually see it). So long as you do the math ahead of time, knowing how many characters you want your bytes to represent or how many bytes your chosen characters will need reserved in your database, either one is reasonable.
数学本身非常简单,因为我们只是在 8 位字节和 5 位字符之间进行转换。这意味着对于 ID 中的每个数据字节,每个字节的 8 位必须由能够容纳 5 位的字符表示。简而言之,这意味着base32Length == Math.ceil(bytes * 8 / 5)。
The math itself is pretty simple, since we’re just converting between 8-bit bytes and 5-bit characters. This means that for each byte of data in an ID, the 8 bits of each of those bytes must be represented by characters capable of holding 5 bits. In short, this means base32Length == Math.ceil(bytes * 8 / 5).
出于说明目的,让我们看看如何生成一个以字节优先为重点的随机标识符。
For illustration purposes, let’s look at how to generate a random identifier focused on bytes-first.
Listing 6.1 Generating a random Base32-encoded identifier
const crypto = require('crypto'); const base32Encode = require('base32-encode'); function generateId(sizeBytes: number): string { ❶ const bytes = crypto.randomBytes(sizeBytes); ❷ return base32Encode(bytes, 'Crockford'); ❸ }
❶ Here, sizeBytes is the number of bytes in the ID.
❷ Start by generating some random bytes.
❸ Return the Crockford Base32 encoding.
现在我们知道如何生成 ID,我们需要确保此 ID 未在 6.4.3 中使用(并且过去也从未使用过)。让我们简要地看看我们如何可能做这个。
Now that we know how to generate an ID, we need to be sure that this ID isn’t in use 6.4.3 (and also wasn’t ever in use in the past). Let’s look briefly at how we might do this.
作为我们在有关标识符永久性的部分中了解到,在 API 的整个生命周期中,标识符的使用次数不得超过一次是至关重要的。这意味着即使资源已被删除,我们也不能重用相同的标识符。有很多方法可以做到这一点,有些方法比其他方法需要更多的存储空间。
As we learned in the section about identifier permanence, it’s critical that identifiers be used no more than one time across the life span of an API. This means that even after a resource has been deleted, we can’t reuse that same identifier. There are lots of ways to do this, some requiring more storage space than others.
一个简单的选择是依靠软删除的想法(在第 25 章中介绍)。在这种设计模式中,我们可以简单地将资源标记为已删除,而不是完全删除资源及其数据(“硬删除”)。然后,从我们的标识符生成代码的角度来看,我们只是继续生成随机值,直到我们遇到一个尚未采用的值,同时检查我们的数据库以确保 ID 确实可用。尽管无论我们是否担心 tomb-stoned 标识符,这种模式都是有用的,但它存在执行时间不可预测的古老问题,从统计上看,随着越来越多的标识符被创建和可用 ID 集,这个问题只会变得更糟用完了。也就是说,对于 120 位标识符,密钥空间的大小与地球上存在的细菌细胞数量大致相同。这意味着两次选择相同的细胞有点像从地球上所有的细菌细胞中挑选一个,然后再次随机挑选完全相同的那个。换句话说,撞车应该是非常罕见的。
One simple option is to rely on the idea of soft deletion (addressed in chapter 25). In this design pattern, rather than actually deleting a resource and its data entirely (“hard deletion”), we could simply mark the resource as deleted. Then, from the perspective of our identifier generation code, we simply continue generating random values until we come across a value that’s not yet taken, checking our database along the way to ensure the ID is actually available. While this pattern can be useful regardless of whether we’re concerned about tomb-stoned identifiers, it suffers from the age-old problem of unpredictable execution time that will statistically only get worse as more and more identifiers are created and the set of available IDs gets used up. That said, with a 120-bit identifier, the key space is about the same size as the number of bacterial cells in existence on the planet. This means that choosing the same one twice is sort of like picking one bacterial cell from all of those on the planet and then randomly picking that exact same one again. In other words, running into a collision should be exceptionally rare.
在依赖软删除模式没有意义的情况下(也许涉及的数据很重要,您必须出于某种原因清除它,例如法规要求),您还有很多其他选择。例如,您可以只存储已在某处的哈希映射中获取的标识符,或者保留在布隆过滤器上并在开始使用时将 ID 添加到该过滤器。也就是说,通过使用相对于您将创建的资源数量足够大的标识符并使用可靠且加密安全的随机源来生成您的资源,仍然可以最好地避免冲突身份标识。
In cases where relying on the soft deletion pattern doesn’t make sense (perhaps the data involved is significant and you must purge it for some reason, for example regulatory requirements), you have lots of other options. For example, you can store just the identifiers that have been taken in a hash-map somewhere or hold onto a Bloom filter and add IDs to that filter as they start being used. That said, collisions are still best avoided by using a sufficiently large identifier relative to the number of resources you’ll be creating and using a reliable and cryptographically secure source of randomness to generate your identifiers.
作为在第 6.3.4 节中提到,计算校验和非常简单,只需将我们的字节值视为整数并使用模 37 值作为校验和值。这意味着我们将数字除以 37,并将余数用作校验和。然而,有时代码胜于雄辩。请注意,我们将依赖BigInttype 用于处理可能由任意长度的字节缓冲区产生的大整数H。
As noted in section 6.3.4, calculating a checksum is as simple as treating our byte value as an integer and using the modulo 37 value as the checksum value. This means we divide the number by 37 and use the remainder as the checksum. Sometimes, however, code speaks a bit louder than words. Note that we’ll rely on the BigInt type for handling large integers that might result from a byte buffer of arbitrary length.
Listing 6.2 Calculating a Base32 checksum value
function calculateChecksum(bytes: Buffer): number { const intValue = BigInt(`0x${bytes.toString('hex')}`); ❶ return Number(intValue % BigInt(37)); ❷ }
❶ Start by converting the byte buffer into a BigInt value.
❷ Calculate the checksum value by determining the remainder after dividing by 37.
此外,我们需要一种方法将此校验和值编码为字符串(而不仅仅是数值)。为此,我们可以在标准 Base32 字母表的基础上添加五个附加字符:*, ~, #, =, 和(由于 Crockford 的 Base32 不区分大小写,U这也包括在解析值时)。u这样,我们的校验和计算得出的任何值(范围从 0 到 36)都将有一个字符值用于标识符中。
Additionally, we’ll need a way to encode this checksum value as a string (rather than just the numeric value). To do this, we can build on the standard Base32 alphabet with five additional characters: *, ~, #, =, and U (this also includes u when parsing the values due to the case insensitivity of Crockford’s Base32). This way, any value that results from our checksum calculation (which will range from 0 to 36) will have a character value to use in the identifier.
Listing 6.3 Using the Base37 alphabet to choose a checksum value character
function getChecksumCharacter(checksumValue: number): string { const alphabet = '0123456789ABCDEFG' + 'HJKMNPQRSTVWXYZ*~$=U'; ❶ return alphabet[Math.abs(checksumValue)]; ❷ }
❶ Define the alphabet (Base32 plus the five additional characters).
❷ Return the character at position checksumValue.
此时,您可能想知道,“好吧,所以我有一个校验和字符。怎么办?” 很简单,我们可以在向用户显示标识符时将此字符附加到我们的 Base32 编码标识符的末尾,并且当提供标识符时,我们可以依赖此校验和字符是正确的。让我们从更新generateId函数开始包括校验和字符。
At this point, you may be wondering, “Okay, so I have a checksum character. Now what?” Quite simply we can append this character to the end of our Base32-encoded identifier when displaying identifiers to users, and when identifiers are provided we can rely on this checksum character being correct. Let’s start by updating the generateId function to include the checksum character.
Listing 6.4 Generating a Base32 identifier with a checksum character
const crypto = require('crypto'); const base32Encode = require('base32-encode'); function generateIdWithChecksum(sizeBytes: number): string { const bytes = crypto.randomBytes(sizeBytes); const checksum = calculateChecksum(bytes); ❶ const checksumChar = getChecksumCharacter(checksum); ❶ const encoded = base32Encode(bytes, 'Crockford'); return encoded + checksumChar; ❷ }
❶ Calculate the checksum and get the correct checksum character.
❷ Return the Base32 serialized identifier with the checksum character appended.
另一方面,我们应该将所有传入的标识符解析为两部分:标识符本身和校验和字符。如果我们检查校验和字符,结果发现它不正确,这并不意味着校验和计算不正确。这实际上意味着标识符本身可能存在拼写错误或其他错误,我们应该拒绝该标识符。为了解这是如何工作的,清单 6.5 实现了一个verifyIdentifier函数返回给定标识符是否应被视为有效的布尔值d.
On the other end, we should parse all incoming identifiers as two pieces: the identifier itself and the checksum character. If we check the checksum character and it turns out to be incorrect, that doesn’t mean that the checksum was calculated incorrectly. It actually means that there was probably a typo or other mistake in the identifier itself, and we should reject the identifier as invalid. To see how this might work, listing 6.5 implements a verifyIdentifier function that returns a Boolean value of whether a given identifier should be considered valid.
Listing 6.5 Verifying a Base32 identifier based on its checksum value
function verifyId(identifier: string): boolean { const value = identifier.substring( 0, identifier.length-1); ❶ const checksumChar = identifier[identifier.length-1]; const buffer = Buffer.from( base32Decode(value, 'Crockford')); ❷ return (getChecksumCharacter(calculateChecksum(buffer)) ➥ == checksumChar); ❸ }
❶ Split the identifier into two pieces: the value and the checksum character.
❷ Decode the Base32 value into its raw bytes.
❸ Return whether the calculated checksum value is equal to the provided one.
现在我们已经介绍了如何计算和验证校验和值,让我们继续讨论所有这些如何从数据存储中工作看法。
Now that we’ve covered how to calculate and verify checksum values, let’s move on to how all of this works from a data store perspective.
为了我们所有关于不使用自动递增整数的讨论,大多数数据库都推荐这个并为此做了很多优化,这导致了一个明显的问题:如果我们使用这个新奇的随机 Base32 编码标识符,我们应该如何存储这样我们的数据库就不会因疲惫或混乱而崩溃?
For all our talk about not using auto-incrementing integers, most databases out there recommend this and have optimized quite a bit for it, which leads to the obvious question: if we use this new-fangled random Base32-encoded identifier, how should we store it so that our databases don’t fall over from exhaustion or confusion?
首先,我们必须决定到底需要将什么存储在数据库中。显然标识符本身需要存储,但是校验和字符呢?该值(以及我们在将标识符交给用户时可能已编码到标识符中的任何其他元数据)不应存储在数据库中。相反,我们应该始终动态计算该值作为传入 ID 的验证步骤(并将其动态附加到传出 ID 上)。如果我们必须更改用于计算校验和值的算法,那么知道我们将不必遍历并重写数据库中的每个条目以反映新的校验和字符,这将是一个很大的安慰。
First, we have to decide what exactly will need to get stored in the database. Obviously the identifier itself will need to be stored, but what about the checksum character? This value (and any other metadata that we may have encoded into the identifier when handing it to users) should not be stored in the database. Instead, we should always calculate that value on the fly as a verification step on incoming IDs (and append it dynamically on outgoing IDs). If we ever have to make changes to the algorithm used in calculating the checksum value, it’ll be a great relief to know we won’t have to go through and rewrite every entry in our database to reflect the new checksum character.
现在我们已经决定唯一要存储的数据是实际标识符,我们需要考虑我们将如何存储它。我们如何存储这些数据有很多不同的选择,但我们将从最简单的字符串开始讨论三种不同的选择。
Now that we’ve decided the only piece of data to store is the actual identifier, we need to consider how exactly we’ll store that. There are lots of different options for how we store this data, but we’ll discuss three different ones, starting with the simplest: strings.
许多数据库(尤其是键值存储系统)非常擅长使用字符串值作为标识符。如果您碰巧使用其中之一,那么存储您的资源标识符将变得非常简单:您只需存储您的资源标识符的字符串表示形式,就像您的客户看到的那样(当然,在删除校验和字符之后)。这基本上是尽可能直接和简单的,所以如果你可以摆脱这个选项,那就去吧。还要记住,Crockford 的 Base32 编码保留了排序顺序,这意味着我们不应该对数据库中的排序与客户端的排序行为有任何不同感到奇怪。
Many databases (particularly key-value storage systems) are very good at using string values as identifiers. If you happen to be using one of those, then storing the identifiers of your resources becomes incredibly simple: you simply store the string representation of your resource identifier as it appears to your customers (after removing the checksum character, of course). This is basically as straightforward and simple as it gets, so if you can get away with this option, go for it. Also keep in mind that Crockford’s Base32 encoding preserves sorting order, which means that we shouldn’t have any weirdness where sorting in the database acts differently than sorting on the client side.
不幸的是,并非所有数据库都像其他数据库一样擅长存储(和索引)字符串值。此外,在存储标识符的字符串表示形式时,您实际上是在浪费空间(因为每个 8 位字符仅代表 5 位实际数据)。考虑到这一点,我们总是可以使用最低级别:存储原始字节。许多数据库非常擅长使用原始字节字段作为标识符。对于这些类型的数据库,我们可以依靠原始字节数据类型(例如,MySQL 称之为 this BINARY)作为我们的主键,强制执行唯一性约束并添加索引,使单键查找查询变得快速和简单。
Unfortunately, not all databases are as good at storing (and indexing) string values as others. Further, when storing the string representation of an identifier you’re actually wasting space (since each 8-bit character only represents 5 bits of actual data). With this in mind, we can always go with the lowest level available: storing raw bytes. Many databases are quite good at using raw bytes fields as identifiers. For those types of databases, we can rely on the raw bytes data type (for example, MySQL calls this BINARY) for our primary key, enforcing uniqueness constraints and adding indexes that make single-key lookup queries fast and simple.
最后,如果我们正确地选择了标识符的大小,我们就可以调整它的大小,使它能够很好地适应常见的整数类型(例如,32 位或 64 位)。如果可能的话,这可能是最好的选择,因为几乎所有的数据库都非常擅长存储和索引数值。为此,我们只需将标识符所代表的字节视为代表一个整数,我们在学习如何计算给定 ID 的校验和值时看到了如何做(例如,byteBuffer.readBigInt64BEInt(0)). 使用整数作为底层存储格式可能是最受支持的选项,因为它几乎适用于所有数据库系统出去那里。
Finally, if we choose the size of an identifier properly, we can size it so that it will fit nicely inside a common integer type (e.g., 32 or 64 bits). If possible, this is probably the best option because almost all databases are exceptionally good at storing and indexing numeric values. To do this, we simply take the bytes represented by our identifier and treat them as representing an integer, which we saw how to do when learning how to calculate the checksum value for a given ID (e.g., byteBuffer.readBigInt64BEInt(0)). Using an integer as the underlying storage format is likely the most commonly supported option, as it will fit with almost all database systems out there.
如果如果您不熟悉 UUID,跳过本节可能是安全的。简而言之,UUID 是一种常见的 128 位标识符格式,具有相对较长的历史(规范 RFC-4122,日期为 2005 年 7 月)。UUID 基本上是一种标准标识符格式,具有许多方便的功能,例如名称间距、大量可用 ID,因此发生冲突的可能性可以忽略不计。我们不会深入了解 UUID 的所有细节,但您可以通过连字符格式识别它们,它们如下所示:123e4567-e89b-12d3-a456-426655440000。
If you’re not familiar with UUIDs, it’s probably safe for you to skip this section. In short, UUIDs are common 128-bit identifier formats with a relatively long history (the specification, RFC-4122, is dated July 2005). UUIDs are basically a standard identifier format with quite a few handy features such as name spacing, lots of available IDs, and therefore a negligible chance of collisions. We won’t get into all the details of UUIDs, but you may recognize them by their hyphenated format, where they look like the following: 123e4567-e89b-12d3-a456-426655440000.
如果您知道 UUID 是什么,并且您已经读到这里并且在想,“为什么我们不直接使用 UUID 并就此结束呢?” 那么您并不孤单(一方面,如果是这种情况,跳过本章会快得多)。简短的回答是,如果您需要 UUID,UUID 是完全可以的,但有三点值得一提,因为我们没有用“只需使用 UUID”这一信息来替换整章。
If you are aware of what UUIDs are and you’ve read this far and are thinking, “Why aren’t we just using UUIDs and calling this a day?” then you’re not alone (for one thing, it would’ve been much faster to just skip this chapter if that were the case). The short answer is that UUIDs are perfectly fine if you need them, but there are three points worth mentioning for why we didn’t replace this entire chapter with the message, “Just use UUIDs.”
首先,UUID 很大(总共 122 位标识符空间,总共 128 位数据),这对于最常见的场景来说有点过分了。在这些情况下,如果您知道您不会创建数万亿的资源,您可能希望使用更短且更易于阅读的 ID 格式。
First, UUIDs are large (122 total bits of identifier space, 128 bits of data in total), which is overkill for the most common scenarios. In cases like these, you might want to use an ID format that is shorter and easier to read if you know you won’t be creating trillions of resources.
其次,字符串格式的 UUID 的信息并不那么密集,因为它们仅依赖十六进制 (Base16) 符号进行传输。简而言之,这意味着我们每个 ASCII 字符仅携带 4 位信息,而使用 Base32 编码时每个字符携带 5 位信息。虽然这对计算机来说可能并不重要(毕竟,如今压缩非常好),但当我们需要通过非技术手段复制和共享这些内容时(例如,通过电话共享标识符),高信息密度当然是一个不错的选择益处。
Next, UUIDs in their string format are not all that informationally dense since they rely only on hexadecimal (Base16) notation for transport. In short, this means that we’re only carrying 4 bits of information per ASCII character, compared to the 5 bits per character we get by using Base32 encoding. While this might not be important for computers (after all, compression is pretty great these days), when we need to copy and share these over nontechnical means (e.g., share the identifier over the phone), high information density can certainly be a nice benefit.
最后,根据定义,UUID 不带有自己的校验和值。这意味着我们没有很好的方法来确定丢失的 UUID 与不可能存在的 UUID(因此可能存在印刷错误)。在这种情况下,向标识符引入一个额外的校验和值是有意义的,这样我们就可以区分这两种情况,也许使用与 Base32 计算校验和字符的方式类似的方法。
Finally, UUIDs, by definition, don’t come with their own checksum value. This means we don’t have a great way to distinguish with certainty between a UUID that is missing versus a UUID that couldn’t possibly have ever existed (and therefore likely has a typographical error present). In cases like this, it would make sense to introduce an additional checksum value to the identifier so that we can tell the difference between these two scenarios, perhaps using a similar method to how Base32 calculates a checksum character.
考虑到所有这些,在内部使用 UUID 值可能是有意义的,但仍然提供 Base32 字符串作为面向客户的标识符。这使您可以受益于 UUID 提供的所有功能,例如名称间距(UUID v3 和 v5)或基于时间戳的值(UUID v1 和 v2),以及 Base32 功能集,例如校验和值、可读性、和更高的信息密度。
With all that in mind, it might make sense to use UUID values internally, but still present a Base32 string as the customer-facing identifier. This allows you to benefit from all of the features UUIDs provide, such as name spacing (UUID v3 and v5) or timestamp-based values (UUID v1 and v2), as well as the Base32 feature set, such as checksum values, readability, and higher information density.
您可能会生成一个带有校验和字符的 Base32 编码的 UUID v4(随机生成)河
You might generate a Base32-encoded UUID v4 (randomly generated) with a checksum character.
清单 6.6 生成一个随机的 Base32 编码的 UUID v4
Listing 6.6 Generating a random Base32-encoded UUID v4
const base32Encode = require('base32-encode'); const uuid4 = require('uuid/v4'); ❶ function generateBase32EncodedUuid(): string { const b = Buffer.alloc(16); ❷ uuid4(null, b); ❸ const checksum = calculateChecksum(b); ❹ const checksumChar = getChecksumCharacter(checksum); ❹ return base32Encode(b, 'Crockford') + checksumChar; ❺ }
❶ In this example, we use the uuid package on NPM.
❷ Start by allocating a 16-byte buffer to hold the UUID.
❸生成 UUID(在本例中为随机 UUID v4)并将其存储在缓冲区中。
❸ Generate the UUID (in this case, a random UUID v4) and store it in the buffer.
❹ Calculate the checksum and get the right Base37 checksum character.
❺返回 Crockford Base32 编码值(和校验和)。
❺ Return the Crockford Base32 encoded value (and the checksum).
如果我们有一个特别擅长存储和索引 UUID 的数据库,而不是将它们存储为字符串、原始字节或整数,我们可能也想这样做,正如我们在 第 6.4.5 节。
We might also want to do this if we have a database that is particularly good at storing and indexing UUIDs, rather than storing these as strings, raw bytes, or integers, as we noted in section 6.4.5.
Design a new encoding and decoding scheme with a larger checksum size, chosen relative to the size of the identifier.
计算随机选择的 2 字节(16 位)标识符发生冲突的可能性。(提示:参考生日问题1。)
Calculate the likelihood of a collision for a randomly chosen 2-byte (16-bit) identifier. (Hint: See the birthday problem1 as a guide.)
Design an algorithm to avoid collisions when using a 2-byte (16-bit) identifier that doesn’t rely on a single counter. (Hint: Think about distributed allocation.)
Identifiers are the values used to uniquely point to specific resources in an API.
Good identifiers are easy to use, unique, permanent, fast and easy to generate, unpredictable, readable, copyable, shareable, and informationally dense.
从客户的角度来看,标识符应该是字符串,使用 ASCII 字符集,最好依赖 Crockford 的 Base32 序列化格式。
From a customer’s perspective, identifiers should be strings, using the ASCII character set, ideally relying on Crockford’s Base32 serialization format.
Identifiers should use a checksum character to distinguish between a resource that doesn’t exist and an identifier that could never point to a resource (and is likely the result of a mistake).
正如我们在第 1 章中了解到的,好的 API 的特征之一是可预测性。构建可预测 API 的一种好方法是仅改变可用资源,同时保持每个人都可以对这些资源执行的一组一致的特定操作(通常称为方法)。这意味着这些方法中的每一个都必须在外观和行为上保持一致,直到最后的细节;否则,当应用于多个资源的相同操作不相同时,可预测性将完全丧失。此模式探讨了在 API 中跨资源实现这些标准方法时应遵循的特定规则。
As we learned in chapter 1, one of the characteristics of a good API is predictability. One great way to build a predictable API is to vary only the resources available while keeping a consistent set of specific actions (often called methods) that everyone can perform on those resources. This means that each of these methods must be consistent in appearance and behavior down to the last detail; otherwise, the predictability is completely lost when the same action isn’t identical when applied to multiple resources. This pattern explores the specific rules that should be followed when implementing these standard methods across the resources in an API.
一设计良好的 API 最有价值的方面之一是用户能够应用他们已经知道的知识,以便更快地理解 API 的工作原理。换句话说,用户对 API 的了解越少,他们就可以越快地开始使用它来实现自己的目标。执行此操作的一种方法(通常在 RESTful API 中表示)是对 API 定义的各种资源使用一组标准操作(或方法)。用户仍然需要了解 API 中的资源,但是一旦他们建立了对这些资源的理解,他们就已经熟悉了一组可以对这些资源执行的标准方法。
One of the most valuable aspects of a well-designed API is the ability for users to apply what they already know in order to more quickly understand how an API works. In other words, the less a user has to learn about an API, the quicker they can start using it to accomplish their own goals. One way to do this, expressed often in RESTful APIs, is to use a set of standard actions (or methods) on a variety of resources defined by the API. Users will still need to learn about the resources in the API, but once they’ve built up their understanding of those resources, they’re already familiar with a set of standard methods that can be performed on those resources.
不幸的是,这只有在每个标准方法都真正标准化的情况下才有效,无论是整个 API 还是更普遍的大多数 Web API。幸运的是,REST 标准已经存在了相当长的一段时间,并为这一级别的 Web API 标准化奠定了基础。在此基础上,该模式概述了所有标准方法以及应应用于每个方法的各种规则,从 REST 中汲取了大量灵感规格。
Unfortunately, this only works if each of the standard methods is truly standardized, both across the API as a whole as well as more generally across the majority of web APIs. Luckily, the REST standard has been around for quite some time and has laid the groundwork for this level of web API standardization. Based on that, this pattern outlines all the standard methods and the various rules that should be applied to each of them, taking quite a lot of inspiration from the REST specifications.
自从我们的目标是推动更高的一致性并以更可预测的 API 结束,让我们首先查看建议的标准方法列表及其总体目标,如表 7.1 所示。
Since our goal is to drive more consistency and end up with a more predictable API, let’s start by looking at the list of proposed standard methods and their overall goals, shown in table 7.1.
Table 7.1 Standard methods overview
虽然这些方法及其描述可能看起来非常简单,但实际上缺少大量信息。当信息缺失时,这会导致歧义。这种歧义最终导致对每个方法应该如何操作的广泛解释,导致相同的“标准”方法在不同的 API 甚至同一 API 中的不同资源中表现完全不同。
While these methods and their descriptions might seem quite straightforward, there is actually a large amount of information missing. And when information is missing, this leads to ambiguity. This ambiguity ultimately leads to a wide range of interpretation of how each method should act, leading to the same “standard” method acting completely different across different APIs or even different resources in the same API.
让我们仔细研究一个示例方法:删除。表 7.1 中的行为描述表明此方法删除现有资源。换句话说,我们可能期望如果资源被创建并且用户删除了它,我们应该得到一个200 OKHTTP 响应代码的等价物并继续我们的快乐方式。但是如果资源还不存在怎么办?响应仍然应该是OK吗?还是应该返回404 Not FoundHTTP 错误?
Let’s look closely at a single method for an example: delete. The behavior description in table 7.1 says that this method removes an existing resource. In other words, we might expect that if a resource was created and a user deletes it, we should get the equivalent of a 200 OK HTTP response code and go on our merry way. But what if the resource doesn’t exist yet? Should the response still be OK? Or should it return a 404 Not Found HTTP error?
有些人可能会争辩说该方法的目的是删除资源,因此如果资源不再存在,那么它就完成了它的工作并应该返回一个OK结果。其他人认为区分结果(执行方法时资源不再存在)和行为(资源被执行的方法专门删除)很重要,因此试图删除不存在的资源应该返回一个Not Found错误,说明资源现在不存在,但是执行这个方法时也不存在。
Some might argue that the intent of the method is to remove the resource, so if the resource no longer exists, then it has done its job and should return an OK result. Others argue that it’s important to distinguish between a result (the resource no longer existing when the method is executed) and the behavior (the resource was specifically removed by this method being executed), and therefore trying to remove a resource that doesn’t exist should return a Not Found error, indicating that the resource does not exist now, but it also did not exist when this method was executed.
此外,如果资源确实存在但尝试执行该方法的用户无权访问该资源怎么办?结果是否应该是404 Not FoundHTTP 错误?或者403 ForbiddenHTTP 错误?这些错误代码是否应该根据资源是否实际存在而有所不同?最终,这种微妙的设计选择是为了防止出现重要的安全问题。在这种情况下,如果未经授权的用户试图检索资源,他们可以确定资源是否真的不存在(通过接收404 Not Found响应)或者资源是否存在,但他们只是无权访问它(通过收到403 Forbidden回复)。通过这样做,没有任何访问权限的人可能会探测系统中存在的资源,并可能在以后将其作为攻击目标。
Further, what if the resource does exist but the user trying to execute the method does not have access to the resource? Should the result be a 404 Not Found HTTP error? Or a 403 Forbidden HTTP error? Should these error codes differ depending on whether the resource actually exists? Ultimately, this subtle design choice is meant to prevent an important security problem. In this case, if an unauthorized user attempts to retrieve a resource, they can determine whether the resource truly doesn’t exist (by receiving a 404 Not Found response) or whether the resource does exist, but they just don’t have access to it (by receiving a 403 Forbidden response). By doing so, it’s possible for someone with no access permissions whatsoever to probe a system for the resources that exist and potentially target them later for attack.
每个 API 设计者都没有充分的理由一遍又一遍地重新回答这些问题。相反,本章中的指南提供了一些标准答案,以随着 API 随时间扩展而优雅地增长的方式确保跨标准方法的一致性(并防止安全漏洞或其他问题)。但是,不仅仅是陈述一堆规则,让我们在接下来的文章中探讨这些类型的细微差别和微妙的问题部分。
There is no good reason for every API designer to re-answer these questions over and over. Instead, the guidelines in this chapter provide some standard answers that ensure consistency (and prevent security leaks or other issues) across standard methods in a way that will grow gracefully as an API expands over time. But rather than just stating a bunch of rules, let’s explore these types of nuanced and subtle issues in the next section.
前我们深入了解每个标准方法的细节,让我们从探索适用于所有标准方法的交叉方面开始,从一个明显的问题开始:所有资源都必须支持所有标准方法是否是一个硬性要求?换句话说,如果我的资源之一是不可变的,该资源是否仍应支持更新标准方法?或者,如果它是永久性的,它是否仍应支持删除标准方法?
Before we get into the specifics of each standard method, let’s begin by exploring the cross-cutting aspects that apply to all standard methods, starting with an obvious question: is it a hard requirement that all resources must support all standard methods? In other words, if one of my resources is immutable, should the resource still support the update standard method? Or if it’s permanent, should it still support the delete standard method?
自从标准化一组方法的目的是为了提高一致性,这个问题甚至可以讨论,这似乎有点奇怪。换句话说,整整一章都在以完全相同的方式实现一组标准行为,只是为了允许一些资源简单地选择完全不实现这些方法,这似乎是虚伪的。这是一个公平的观点,但归根结底,实用性仍然是 API 设计的一个关键组成部分,并且根本无法逃避某些资源可能不想支持所有标准方法的现实场景。简而言之,并非每种资源类型都需要每种标准方法。
Since the goal of standardizing a set of methods is all about driving consistency, it may seem a bit curious that this question is even up for discussion. In other words, it might seem hypocritical that there’s an entire chapter on implementing a set of standard behaviors in the exact same way, only to then allow some resources to simply opt out of implementing the methods at all. And that’s a fair point, but when it comes down to it, practicality is still a critical component of API design, and there’s simply no way to escape the real-life scenarios where certain resources might not want to support all standard methods. In short, not every standard method is required for each resource type.
虽然有些场景与其他场景不同,但区分不应完全支持方法的情况(例如,返回相当于405 Method Not AllowedHTTP 错误的情况)将变得很重要) 以及在此特定实例上根本不允许使用方法的情况(返回403 ForbiddenHTTP 错误的等效项). 但不应该发生的情况是,API 简单地省略了特定路由(例如,更新方法)并返回404 Not FoundHTTP 错误每当有人试图在资源上使用该方法时。如果 API 执行此操作,则意味着该资源不存在,但事实并非如此:从技术上讲,该方法对于该资源类型不存在。
Some scenarios are different from others though, and it’ll become important to distinguish between cases where a method shouldn’t be supported entirely (e.g., returning the equivalent of a 405 Method Not Allowed HTTP error) and cases where a method is simply not permitted on this specific instance (returning the equivalent of a 403 Forbidden HTTP error). What should never happen though, is a scenario where an API simply omits a specific route (e.g., the update method) and returns a 404 Not Found HTTP error whenever someone attempts to use that method on a resource. If an API were to do this, the implication is that the resource doesn’t exist, which isn’t true: it’s the method that technically doesn’t exist for that resource type.
在决定是否支持每个标准方法时有哪些规则?虽然没有明确的要求,但一般准则是每个资源都应该存在每个标准方法,除非有充分的理由不这样做(并且应该记录和解释这个原因)。例如,如果一个资源实际上是一个单例子资源(参见第 12 章),则该资源是一个单例并且仅由于其父存在而存在。结果,只有 get 和 update 标准方法才有意义;其余的可以完全忽略。这也是一个示例,其中该方法根本没有意义,因此应该返回405 Method Not AllowedHTTP 错误的等价物。
What are the rules when deciding whether to support each standard method? While there are no firm requirements, the general guideline is that each standard method should exist on each resource unless there is a good reason for it not to (and that reason should be documented and explained). For example, if a resource is actually a singleton sub-resource (see chapter 12), the resource is a singleton and exists only by virtue of its parent existing. As a result, only the get and update standard methods make any sense; the rest can be ignored entirely. This is also an example of a case where the method fundamentally doesn’t make sense and as a result should return the equivalent of a 405 Method Not Allowed HTTP error.
在其他情况下,某些方法可能对资源的特定实例没有意义,但对于资源类型在概念上仍然有意义。例如,如果存储系统在特定目录上有一个“写保护”标志,以防止对该目录内的资源进行修改,API 仍应支持标准方法(例如,更新和删除),但在有人尝试时返回错误在当前不允许的情况下对资源执行这些操作。此场景还涵盖了可能被视为永久(即永远不应删除)或不可变(即永远不应修改)的资源类型。仅仅因为它们现在是永久的和不可变的并不意味着它们永远不会在未来。因此,实现完整的标准方法集并简单地返回一个值是最有意义的403 Forbidden每当有人试图修改或删除它们时的错误代码。
In other cases, certain methods might not make sense for a specific instance of a resource but still make conceptual sense for the resource type. For example, if a storage system has a “write protection” flag on a specific directory that prevents modifications on resources living inside that directory, the API should still support the standard methods (e.g., update and delete) but return an error when someone attempts to perform those actions on the resource if they’re not currently permitted. This scenario also covers resource types that might be considered permanent (i.e., should never be deleted) or immutable (i.e., should never be modified). Just because they are permanent and immutable now does not mean they will never be in the future. As a result, it makes the most sense to implement the complete set of standard methods and simply return a 403 Forbidden error code whenever anyone attempts to modify or delete them.
现在我们对何时定义哪些方法有了一些指导方针,让我们看一下标准方法的核心假设之一:缺乏边效果。
Now that we have some guidelines on when to define which methods, let’s look at one of the core assumptions of standard methods: a lack of side effects.
自从标准方法的一个关键部分是它们的可预测性,标准方法应该完全按照他们说的去做,仅此而已,这不足为奇。简而言之,标准方法不应有任何副作用或意外行为。此外,某些方法具有称为幂等性的特定属性,它指示多次重复相同的请求(使用相同的参数)是否与单个请求具有相同的效果。在特定方法上打破普遍持有的关于此属性的假设可能会导致灾难性后果,例如数据丢失或损坏。
Since a critical piece of standard methods is their predictability, it should come as no surprise that standard methods should do exactly what they say they’ll do and nothing more. Put simply, standard methods should not have any side effects or unexpected behavior. Further, some methods have a specific property called idempotence, which indicates whether repeating the same request (with the same parameters) multiple times will have the same effect as a single request. Breaking the assumptions generally held about this property on specific methods can lead to disastrous consequences, such as data loss or corruption.
确定什么是可接受的并且不违反无副作用规则可能很复杂,因为许多 API 都有需要额外行为的场景,超出了简单标准方法所指示的行为,而且标准方法通常看起来好像是该行为存在的正确位置。当额外的行为是微妙的或者没有真正改变任何有意义的状态时,这可能会导致更多的混乱,以至于我们可能仍然认为该方法是幂等的。
Determining what is acceptable and not breaking the no-side-effects rule can be complicated, as many APIs have scenarios where extra behavior is required, beyond that indicated by just a simple standard method, and often it can seem as though the standard method is the right place for that behavior to live. This can lead to even more confusion when the extra behavior is subtle or doesn’t really change any meaningful state, so much so that we might still consider the method idempotent.
什么是副作用?有些是显而易见的,例如在电子邮件 API 中,创建电子邮件并将信息存储在数据库中不会被视为副作用。但是,如果该标准创建方法也通过 SMTP 连接到服务器以发送该电子邮件,这将被视为副作用(因此应避免作为标准创建方法的一部分)。
What constitutes a side effect? Some are obvious, for example in an email API, creating an email and storing the information in a database would not be considered a side effect. But if that standard create method also connected via SMTP to a server to send that email, this would be considered a side effect (and therefore avoided as part of the standard create method).
请求计数器怎么样,每次您检索资源时,它都会递增一个计数器以跟踪通过标准 get 方法检索资源的次数?从技术上讲,这意味着这种方法不再是幂等的,因为状态在底层发生变化,但这真的是一个大问题吗?复杂的答案是视情况而定。计数器更新是否对性能有影响,请求会比其他方式慢得多或变化更大?如果由于任何原因更新计数器失败会怎样?在资源检索失败的情况下是否有可能仅仅因为计数器存在技术问题导致无法更新而产生错误响应?
What about a request counter, where every time you retrieve a resource it increments a counter to keep track of how many times it has been retrieved via a standard get method? Technically, this means this method is no longer idempotent as state is changing under the hood, but is this really such a big problem? The complicated answer is it depends. Does the counter updating have a performance implication where the request will be significantly slower or vary more than it otherwise would? What happens if updating the counter fails for any reason? Is it possible to have an error response where the resource retrieval fails simply because the counter had a technical issue that prevented it from being updated?
最后,正如我们将在第 24 章定义版本控制策略中了解到的那样,您对什么构成副作用的判断有点难以解释。虽然应该不惜一切代价避免明显的副作用(例如连接到第三方或外部服务或触发可能导致标准方法失败或导致部分执行的请求的额外工作),但一些更微妙的情况可能给定消费者期望的 API 才有意义。
Ultimately, as we’ll learn in chapter 24 with defining a versioning policy, your judgment of what constitutes a side effect is a bit open to interpretation. While the blatant side effects (such as connecting to third-party or external services or triggering additional work that may cause the standard method to fail or result in a partially executed request) should be avoided at all costs, some of the more subtle cases might make sense for an API given consumer expectations.
现在我们已经涵盖了标准方法的一般方面,让我们深入研究每个方法并探索我们需要为每个方法考虑的一些内容,首先从只读方法开始,然后继续休息。
Now that we’ve covered the general aspects of standard methods, let’s dig into each method and explore some of what we’ll need to consider for each one, starting with the read-only methods first and continuing with the rest.
这标准 get 方法的目标非常简单:服务在某处存储了资源记录,此方法返回存储在该记录中的数据。为此,该方法只接受一个输入:相关资源的标识符。换句话说,这严格来说是存储数据的键值查找。
The goal of the standard get method is very straightforward: the service has a resource record stored somewhere and this method returns the data stored in that record. To accomplish this, the method accepts only a single input: the identifier of the resource in question. In other words, this is strictly a key-value lookup of the data stored.
Listing 7.1 An example of the standard get method
abstract class ChatRoomApi { @get("/{id=chatRooms/*}") ❶ GetChatRoom(req: GetChatRoomRequest): ChatRoom; ❷ } interface GetChatRoomRequest { id: string; }
❶ The standard get method always retrieves a resource by its unique identifier.
❷ The result of a standard get method should always be the resource itself.
与大多数不会主动更改基础数据的方法一样,此方法应该是幂等的,这意味着您可以自由运行它多次,并且假设没有其他更改同时发生,每次的结果都应该相同。这也意味着该方法与所有标准方法一样,不应有任何明显的副作用。
As with most methods that don’t actively alter the underlying data, this method should be idempotent, meaning you should be free to run it multiple times and, assuming no other changes are happening concurrently, the result should be the same each time. This also means that this method, like all standard methods, should not have any noticeable side effects.
虽然关于标准 get 方法还有很多要讨论的内容,例如检索特定的资源修订版,但我们将把这些主题留到以后在特定的未来设计模式中讨论。现在,让我们继续下一个只读标准方法:列表。
While there is plenty more to discuss about a standard get method, such as retrieving specific resource revisions, we’ll leave these topics for later discussion in specific future design patterns. For now, let’s move on to the next read-only standard method: list.
自从标准的get方法只是一个键值查找,我们显然需要另一种机制来浏览可用的资源,list标准方法就是这样。在列表方法中,您提供需要浏览的特定集合,结果是属于该集合的所有资源的列表。
Since the standard get method is exclusively a key-value lookup, we obviously need another mechanism to browse the available resources, and the list standard method is just that. In a list method, you provide a specific collection that you need to browse through and the result is a listing of all the resources belonging to that collection.
需要注意的是,标准列表方法与标准获取方法的目标不同:我们不是请求特定资源,而是请求属于特定集合的资源列表。该集合本身可能属于另一个特定资源,但情况并非总是如此。例如,考虑我们可能拥有一组ChatRoom资源的情况,其中每一个都包含一个Message资源集合. 列出ChatRoom资源需要以chatRooms集合为目标,而列出Message给定的资源将以集合为ChatRoom目标messages, 属于具体ChatRoom.
It’s important to note that the standard list method is different from the standard get method in its target: instead of asking for a specific resource, we instead ask for a list of resources belonging to a specific collection. This collection might itself belong to another specific resource, but that’s not always the case. For example, consider the case where we might have a set of ChatRoom resources, each of which contains a collection of Message resources. Listing ChatRoom resources would require targeting the chatRooms collection, whereas listing the Message resources for a given ChatRoom would target the messages collection, belonging to the specific ChatRoom.
Listing 7.2 An example of the standard list method
abstract class ChatRoomApi { @get("/chatRooms") ❶ ListChatRooms(req: ListChatRoomsRequest): ListChatRoomsResponse; @get("/{parent=chatRooms/*}/messages") ❷ ListMessages(req: ListMessagesRequest): ListMessagesResponse; } interface ListChatRoomsRequest { filter: string; } interface ListChatRoomsResponse { results: ChatRoom[]; } interface ListMessagesRequest { parent: string; filter: string; } interface ListMessagesResponse { results: Message[]; }
❶ When listing resources belonging to a top-level collection, there is no target!
❷当列出属于资源的子集合的资源时,目标是父资源(在本例中为 ChatRoom 资源)。
❷ When listing resources belonging to a subcollection of a resource, the target is the parent resource (in this case, the ChatRoom resource).
虽然列出一组资源的概念可能看起来与标准的 get 方法一样简单,但实际上它的功能远比看上去的要多得多。虽然我们不会讨论有关此方法的所有内容(例如,某些方面将在未来的设计模式中涵盖,例如第 21 章),但这里有一些主题值得讨论,首先是访问控制。
While the concept of listing a set of resources might seem just as simple as the standard get method, it actually has quite a lot more to it than meets the eye. While we won’t address everything about this method (e.g., some aspects are covered in future design patterns, such as in chapter 21), there are a few topics worth covering here, starting with access control.
尽管讨论太多细节没有意义,有一个重要的方面值得一提:当不同的请求者可以访问不同的资源时,list 方法应该如何表现?换句话说,如果您想列出ChatRoomAPI 中的所有可用资源,但您只能访问其中的一些资源,该怎么办?API应该做什么?
While it doesn’t make sense to go into too much detail, there is an important aspect worth covering: how should the list method behave when different requesters have access to different resources? Put differently, what if you want to list all the available ChatRoom resources in an API but you only have access to see some of those resources? What should the API do?
API 方法的行为保持一致显然很重要,但这是否意味着每个请求者的每个响应都必须相同?在这种情况下,答案是否定的。响应应仅包含请求者有权访问的资源。这意味着,如您所料,列出相同资源的不同人将获得不同的数据视图。
It’s obviously important that API methods are consistent in their behavior, but does that mean that every response must be the same for every requester? In this scenario, the answer is no. The response should only include the resources the requester has access to. This means that, as you’d expect, different people listing the same resources will get different views of the data.
如果可能,可以布置资源以尽量减少此类情况(例如,确保单用户资源具有单独的父资源),而不是403 Forbidden在列出不受请求者保护的资源时导致简单的错误响应;然而,无法避免的事实是,有时用户可以访问集合中的某些项目,但不能全部。
If possible, resources could be laid out to minimize scenarios like these (e.g., ensure single-users resources have a separate parent), leading instead to simple 403 Forbidden error responses when listing resources that are secured from the requester; however, there is simply no avoiding the fact that there will be times when a user has access to some items in a collection but not all.
下一个,通常会在清单中包含物品的数量。虽然这对于用户界面消费者来说显示匹配结果的总数可能是件好事,但随着时间的推移,列表中的项目数量超过最初预计的数量,这通常会增加更多的麻烦。对于并非旨在提供对匹配特定查询的计数的快速访问的分布式存储系统而言,这尤其复杂。简而言之,在标准列表方法的响应中包含项目计数通常不是一个好主意。
Next, there is often the temptation to include a count of the items along with the listing. While this might be nice for user-interface consumers to show a total number of matching results, it often adds far more headache as time goes on and the number of items in the list grows beyond what was originally projected. This is particularly complicated for distributed storage systems that are not designed to provide quick access to counts matching specific queries. In short, it’s generally a bad idea to include item counts in the responses to a standard list method.
如果某种结果计数是绝对必要的,或者集合中涉及的数据保证保持足够小以处理计数结果而没有任何极端的计算负担,那么使用某种估计并依靠它来指示几乎总是更有效结果总数而不是精确计数。如果计数是估计值而不是精确计数,那么根据实际情况命名该字段也很重要(例如,resultCountEstimate) 以避免对值的准确性产生潜在的混淆。为了给意外留出喘息的空间,如果您认为结果计数既重要又可行,那么将该字段命名为估计值仍然是个好主意(即使该值是精确计数)。这意味着如果您将来需要更改估计值以减轻存储系统的负载,您可以自由地进行,而不会给任何人造成任何困惑或困难。毕竟,准确的计数在技术上只是非常准确的估计。
If some sort of result count is absolutely necessary, or the data involved in the collection is guaranteed to remain small enough to handle counting results without any extreme computation burden, it’s almost always more efficient to use some sort of estimate and rely on that to indicate the total number of results rather than an exact count. And if the count is an estimate rather than an exact count, it’s also important that the field be named for what it is (e.g., resultCountEstimate) to avoid potential confusion about the accuracy of the value. To leave breathing room for the unexpected, if you decide that result counts are both important and feasible, it’s still a good idea to name the field as an estimate (even if the value is an exact count). This means that if you need to change to estimates in the future to alleviate the load on the storage system, you’re free to do that without causing any confusion or difficulty for anyone. After all, an exact count is technically just a very accurate estimate.
为了类似的原因,通常也不鼓励对项目列表进行排序。就像显示结果计数一样,对结果进行排序的能力是一个常见的可有可无的功能,特别是对于呈现用户界面的消费者而言。不幸的是,允许在标准列表方法中对结果进行排序可能会导致比显示结果计数更加困难。
For similar reasons, applying ordering over a list of items is also generally discouraged. Just like showing a result count, the ability to sort the results is a common nice-to-have feature, particularly for consumers who are rendering user interfaces. Unfortunately, allowing sorting over results in a standard list method can lead to even more difficulty than displaying result counts.
虽然在 API 生命周期的早期允许排序可能很容易,但随着时间的推移,对于相对较少的数据,它几乎肯定会变得越来越复杂。在列表中返回更多项目需要更多服务器处理时间来处理排序,但最重要的是,很难将全局排序顺序应用于从多个不同存储后端组装的列表(如分布式存储中的情况)系统)。例如,考虑尝试整合和分类分布在 100 个不同存储后端的 10 亿个项目。对于离线处理作业中的静态数据来说,这是一个复杂的问题(即使每个数据源首先对数据的较小部分进行排序),但对于像实时 API 这样快速变化的数据集来说甚至更复杂。进一步,
While allowing sorting might be easy to do early on in the life cycle of an API, with relatively small amounts of data it will almost certainly become more and more complicated as time goes on. More items being returned in a list requires more server processing time to handle the sorting, but, most importantly, it’s very difficult to apply a global sort order to a list that is assembled from several different storage backends (as is the case in distributed storage systems). For example, consider trying to consolidate and sort 1 billion items spread across 100 different storage backends. This is a complicated problem to solve with static data in an offline processing job (even if each data source sorts its smaller portion of the data first) but even more complex for a rapidly changing data set like a live API. Further, executing this type of sorting on demand each time a user wants to view their list of resources can very easily result in overloaded API servers.
总而言之,这个微小的小功能往往会在未来增加大量的复杂性,而对 API 使用者的价值相对较小。结果,就像总结果计数一样,它通常是一个不好的主意。
All in all, this tiny little feature tends to add a significant amount of complexity in the future for relatively little value to the API consumer. As a result, just like a total result count, it is generally a bad idea.
尽管不鼓励对标准列表方法中涉及的项目进行排序和计数,过滤该方法的结果以使其更有用是一种常见且因此受到鼓励的功能。这是因为应用过滤器的替代方法(例如,请求所有项目并在事后过滤结果)非常浪费处理时间和网络带宽。
While ordering and counting the items involved in a standard list method is discouraged, filtering the results of that method to be more useful is a common, and therefore encouraged, feature. This is because the alternatives to applying a filter (e.g., requesting all items and filtering the results after the fact) are exceptionally wasteful of both processing time and network bandwidth.
在实施过滤时,有一个重要的警告。虽然设计一个严格类型的过滤结构来支持过滤可能很诱人(例如,具有针对各种条件的模式,以及“和”和“或”评估条件),但这种类型的设计通常不会很好地老化. 毕竟,SQL 仍然接受作为简单字符串的查询是有充分理由的。正如在第 5 章中看到的枚举一样,当我们扩展可用于过滤的功能或不同类型时,客户端需要升级其本地模式以利用这些更改。因此,在鼓励过滤的同时,传递过滤器本身的数据类型的最佳选择是字符串,然后可以由服务器解析并在返回结果之前应用,如果可能,通过将解析后的过滤器传递给存储系统,或者直接通过 API 服务。第 22 章更详细地讨论了这个主题。
When it comes to implementing filtering, there is an important caveat. While it can be tempting to design a strictly typed filtering structure to support filtering (e.g., with a schema for the various conditions, as well as “and” and “or” evaluation conditions), this type of design typically does not age very well. After all, there’s a good reason SQL still accepts queries as simple strings. Just like enumerations, as seen in chapter 5, as we expand the functionality or different types available for filtering, clients are required to upgrade their local schemas in order to take advantage of these changes. As a result, while filtering is encouraged, the best choice of data type for conveying the filter itself is a string, which can then be parsed by the server and applied before returning results, either by passing the parsed filter to the storage system if possible, or directly by the API service. This topic is discussed in much more detail in chapter 22.
到目前为止,我们一直专注于从 API 中读取数据。让我们换个话题,看看我们如何使用标准将数据导入 API创建方法。
So far, we’ve focused on reading data out of an API. Let’s switch gears and look at how we get data into an API using the standard create method.
两者都不除非 API 中有一些数据,否则标准 get 方法(第 7.3.3 节)或标准列表方法(第 7.3.4 节)没有任何用处。将数据导入任何 API 的主要机制是使用标准的创建方法。标准创建方法的目标很简单:给定有关资源的一些信息,在 API 中创建该新资源,以便可以通过其标识符检索或通过列出资源来发现。ChatRoom清单 7.3 中显示了创建和Message资源的示例,以查看这可能是什么样子。如您所见,请求(通过 HTTPPOST方法发送) 包含有关资源的相关信息,结果响应始终是新创建的资源。此外,目标要么是父资源(如果可用),要么就是顶级集合(例如,"/chatRooms").
Neither the standard get method (section 7.3.3) nor the standard list method (section 7.3.4) is of any use unless there is some data in the API. And the primary mechanism to get data into any API is to use a standard create method. The goal of the standard create method is simple: given some information about a resource, create that new resource in the API such that it can be retrieved by its identifier or discovered by listing resources. To see what this might look like, an example of creating ChatRoom and Message resources is shown in listing 7.3. As you can see, the request (sent via a HTTP POST method) contains the relevant information about the resource, and the resulting response is always the newly created resource. Additionally, the target is either the parent resource, if available, or nothing more than a top-level collection (e.g., "/chatRooms").
Listing 7.3 An example of the standard create method
abstract class ChatRoomApi { @post("/chatRooms") ❶ CreateChatRoom(req: CreateChatRoomRequest): ChatRoom; ❷ @post("/{parent=chatRooms/*}/messages") ❶ CreateMessage(req: CreateMessageRequest): Message; ❷ } interface CreateChatRoomRequest { resource: ChatRoom; } interface CreateMessageRequest { parent: string; resource: Message; }
❶在这两种情况下,标准创建方法都使用 POST HTTP 动词。
❶ In both cases, the standard create method uses the POST HTTP verb.
❷ The standard create method always returns the newly created resource.
虽然创建资源背后的概念很简单,但很少有值得深入探讨的领域,因为它们比看上去要复杂一些。让我们首先简要了解一下我们如何识别这些新创建的资源。
While the concept behind creating resources is simple, there are few areas worth exploring in more detail as they’re a bit more complicated than meets the eye. Let’s start by taking a brief look at how we identify these newly created resources.
作为我们在第 6 章中了解到,通常最好依赖服务器为新创建的资源生成的标识符。换句话说,我们应该让 API 自己选择资源的标识符,而不是尝试自己选择。也就是说,在许多情况下,让 API 的使用者选择标识符更有意义(例如,如果 API 正由打算同步本地资源集与远程资源集的移动设备使用)。在这些类型的场景中,允许消费者选择 ID 是完全可以接受的;但是,他们应遵循第 6 章中提供的准则。
As we learned in chapter 6, it’s usually best to rely on server-generated identifiers for newly created resources. In other words, we should let the API itself choose the identifier for a resource rather than attempting to choose one ourselves. That said, there are many cases where it makes more sense to let the consumer of the API choose the identifier (e.g., if the API is being used by a mobile device that intends to synchronize a local versus remote set of resources). In those types of scenarios, it is perfectly acceptable to allow consumer-chosen IDs; however, they should follow the guidelines provided in chapter 6.
如果您必须在创建资源时提供资源标识符,则应通过在资源接口本身中设置 ID 字段来完成。例如,要创建ChatRoom具有特定标识符的资源,我们会发出类似于 的 HTTP 请求POST /chatRooms { id: 1234, ... }。正如我们稍后将看到的那样,有一种替代方法可以使用半标准替换方法,但由于并非所有 API 都应支持该方法,因此最好将标识符设置为标准创建的一部分方法。
If you must provide a resource identifier at the time it’s being created, this should be done by setting the ID field in the resource interface itself. For example, to create a ChatRoom resource with a specific identifier, we would make an HTTP request looking something like POST /chatRooms { id: 1234, ... }. As we’ll see later on, there’s an alternative to this with the semi-standard replace method, but since not all APIs are expected to support that method, it’s probably best to focus on setting identifiers as part of a standard create method.
年以前,几乎可以保证你创建的每一点数据都存储在某个地方的单个数据库中,通常是关系数据库服务,如 MySQL 或 PostgreSQL。然而,如今有更多的存储选项可以水平扩展而不会随着数据集的增长而下降。换句话说,当数据库的配置数据太多时,您可以简单地打开更多的存储节点,系统将以更大的数据量运行得更好。
Years ago, it was almost guaranteed that every bit of data you create would be stored in a single database somewhere, usually a relational database service like MySQL or PostgreSQL. Nowadays, however, there are many more storage options that scale horizontally without falling over as the data set grows. In other words, when you have too much data for the database as it’s configured, you can simply turn on more storage nodes and the system will perform better with the larger data size.
这些系统对于海量数据集和异常大量的请求的困扰(和礼物)是一种神奇的治疗方法,但它们通常会带来自己的一系列权衡。最常见的一种与一致性有关,最终一致性作为看似无限可扩展性的常见副作用。在最终一致的存储系统中,随着时间的推移,数据会在系统周围复制,但通常需要一段时间才能到达所有可用节点,这导致数据更新但尚未立即复制到所有地方的世界。这意味着您可以创建一个资源,但它可能在相当长的一段时间内不会出现在列表请求中。更糟糕的是,根据路由的配置方式,您可能会创建一个资源,404 Not Found但当您尝试通过标准 get 方法检索同一资源时会收到 HTTP 错误。
These systems have been a miraculous cure for the plague (and gift) of enormous data sets and extraordinarily large numbers of requests, but they often come with their own set of trade-offs. One of the most common is related to consistency, with eventual consistency being a common side effect of the seemingly infinite scalability. In eventually consistent storage systems, data is replicated around the system over time, but it usually takes a while to arrive at all the available nodes, leading to a world where data is updated but hasn’t been replicated everywhere right away. This means you may create a resource but it might not show up in a list request for quite some time. Even worse, depending on how routing is configured, it might be possible that you create a resource but then get an HTTP 404 Not Found error when you attempt to retrieve that same resource via a standard get method.
虽然这可能是不可避免的,具体取决于所使用的技术,但如果可能的话,绝对应该避免。API 最重要的方面之一是它的事务行为,而其中的一个关键方面是强一致性. 简而言之,这意味着您应该始终能够立即阅读您的写入。换句话说,这意味着一旦您的 API 表明您已经创建了一个资源,它就应该在每个意义上都被创建并且可用于所有其他标准方法。这意味着您应该能够在标准列表方法中查看它,使用标准获取方法检索它,使用标准更新方法修改它,甚至使用标准删除方法将其从系统中删除。
While this might be unavoidable depending on the technology being used, it should absolutely be avoided if at all possible. One of the most important aspects of an API is its transactional behavior, and one of the key aspects of that is strong consistency. This means, in short, that you should always be able to immediately read your writes. Put differently, it means that once your API says you’ve created a resource, it should be created in every sense of the word and available to all the other standard methods. That means you should be able to see it in a standard list method, retrieve it with a standard get method, modify it with a standard update method, and even remove it from the system with a standard delete method.
如果您发现自己处于某些数据无法以这种方式管理的位置,您应该认真考虑使用自定义方法(参见第 9 章)来加载有问题的数据。原因很简单:API 对标准方法的一致性有期望,但正如我们将在第 9 章中看到的那样,自定义方法没有这些期望。因此,与其采用最终一致的CreateLogEntry方法,考虑使用自定义导入方法,例如ImportLogEntries,这将向任何潜在用户解释结果最终在整个系统中是一致的。如果您可以确定跨系统的数据复制何时完成,另一种选择是依赖长时间运行的操作,我们将在第 10 章中更详细地探讨这一点。
If you find yourself in a position where you have some data that cannot be managed in this way, you should seriously consider using a custom method (see chapter 9) to load the data in question. The reason is pretty simple: APIs have expectations about the consistency of standard methods but, as we’ll see in chapter 9, custom methods come with none of those expectations. So rather than having an eventually consistent CreateLogEntry method, consider using a custom import method such as ImportLogEntries, which would explain to any potential users that the results are eventually consistent across the system. Another option, if you can be certain when the data replication across the system is complete, would be to rely on long-running operations, which we’ll explore in more detail in chapter 10.
现在我们对创建新资源所涉及的微妙之处有了一些了解,让我们看看我们如何使用标准修改现有资源更新方法。
Now that we have some idea of the subtleties involved in creating new resources, let’s look at how we modify existing resources with the standard update method.
一次将新资源加载到 API 中,除非资源本身是不可变的并且永远不会更改,否则我们将需要一种直接的方法来修改资源,这将我们引向标准更新方法。此方法的目标是更改有关单个资源的一些现有信息,因此应避免副作用,如第 7.3.2 节所述。
Once a new resource is loaded into an API, unless the resource is itself immutable and intended to never be changed, we’ll need a straightforward way to modify the resource, which leads us to the standard update method. The goal of this method is to change some existing information about a single resource, and as a result it should avoid side effects, as noted in section 7.3.2.
更新资源的推荐方式依赖于 HTTPPATCH方法,使用其唯一标识符指向特定资源并返回新修改的资源。该方法的一个关键方面PATCH是它对资源进行部分修改而不是完全替换。我们将更详细地探讨这一点,因为关于该主题有很多要讨论的内容(例如,您如何区分“不要更新此字段”与“将此字段设置为空白”的用户意图),但是目前的关键是标准更新方法应该只更新 API 消费者明确请求的资源的各个方面。
The recommended way to update a resource relies on the HTTP PATCH method, pointing to a specific resource using its unique identifier and returning the newly modified resource. One key aspect of the PATCH method is that it does partial modification of a resource rather than a full replacement. We’ll explore this in much more detail, as there’s quite a bit to discuss on the topic (e.g., how do you distinguish between user intent of “don’t update this field” versus “set this field to blank”), but for now the key takeaway is that a standard update method should update only the aspects of a resource explicitly requested by the API consumer.
Listing 7.4 Example of the standard update method
abstract class ChatRoomApi { @patch("/{resource.id=chatRooms/*}") ❶ UpdateChatRoom(req: UpdateChatRoomRequest): ChatRoom; } interface UpdateChatRoomRequest { resource: ChatRoom; // ... ❷ }
❶标准更新方法使用 HTTP PATCH 动词来仅修改资源的特定部分。
❶ The standard update method uses the HTTP PATCH verb to modify only specific pieces of the resource.
❷ We’ll learn how to safely handle partial updates of resources in chapter 8.
标准更新方法是修改现有资源的理想场所,但仍有一些场景最好在其他地方进行更新。诸如从一种状态过渡到另一种状态的场景可能会通过一些替代操作来完成。例如,与其将ChatRoom资源的状态设置为已存档,不如ArchiveChatRoom()自定义方法(见第 9 章)将用于完成此任务。简而言之,虽然更新方法是修改现有资源的标准机制,但它远非完成此操作的唯一方法这个。
The standard update method is the perfect place to modify an existing resource, but there are still some scenarios where updates are best done elsewhere. Scenarios like transitioning from one state to another are likely to be accomplished by some alternative action. For example, rather than setting a ChatRoom resource’s status to archived, it’s much more likely that an ArchiveChatRoom() custom method (see chapter 9) would be used to accomplish this. In short, while an update method is the standard mechanism to modify an existing resource, it is far from the only way to accomplish this.
一次资源在 API 中已超出其用途,我们需要一种方法将其删除。这正是标准删除方法的目的。清单 7.5 显示了该方法如何查找删除ChatRoomAPI 中的资源。如您所料,该方法依赖于 HTTPDELETE方法并通过其唯一标识符针对相关资源。此外,与大多数其他标准方法不同,此处的返回值类型是一条空消息,如void我们的 API 定义中所述。这是因为标准删除方法的成功结果是让资源完全消失y。
Once a resource has outlived its purpose in an API, we’ll need a way to remove it. This is exactly the purpose of the standard delete method. Listing 7.5 shows how this method might look for deleting a ChatRoom resource in an API. As you’d expect, the method relies on the HTTP DELETE method and is targeted at the resource in question via its unique identifier. Further, unlike most other standard methods, the return value type here is an empty message, expressed as void in our API definition. This is because the successful result of a standard delete method is to have the resource disappear entirely.
Listing 7.5 The standard delete method
abstract class ChatRoomApi { @delete("/{id=chatRooms/*}") ❶ DeleteChatRoom(req: DeleteChatRoomRequest): void; ❷ } interface DeleteChatRoomRequest { id: string; }
❶标准的删除方法使用 HTTP DELETE 动词来删除资源。
❶ The standard delete method uses the HTTP DELETE verb to remove the resource.
❷ The result is an empty response message rather than an actual response interface.
尽管这种方法在目的上也非常简单,关于这是否被认为是幂等的以及如何处理删除已删除资源的请求存在一些潜在的混淆。最终,这归结为标准删除方法是更注重结果(声明式)还是更注重操作(命令式)的问题。一方面,可以将删除资源想象成简单地请求声明您的意图是让相关资源不再存在。在这种情况下,删除一个已经被删除的资源应该被认为是成功的,因为这个简单的事实是资源在请求完成处理后就消失了。换句话说,使用相同的参数连续两次执行标准删除方法会得到相同的成功结果,
While this method is also very straightforward in purpose, there is a bit of potential confusion about whether this is considered idempotent and how to handle requests to delete already deleted resources. Ultimately, this comes down to a question of whether the standard delete method is more result focused (declarative) or action focused (imperative). On the one hand, it’s possible to picture deleting a resource as simply making a request to declare that your intent is for the resource in question to no longer exist. In that case, deleting a resource that has already been deleted should be considered a success due to the simple fact that the resource is gone after the request finished processing. In other words, executing the standard delete method twice in a row with the same parameters would have the same successful result, therefore making the method idempotent, shown in figure 7.1.
Figure 7.1 A declarative view on the standard delete method results in idempotent behavior.
在命令式方面,可以将标准删除方法想象成请求采取操作,该操作是删除资源。因此,如果在收到请求时资源不存在,服务将无法执行预期的操作并且请求将导致失败。换句话说,尝试连续两次删除同一资源可能导致第一次成功但第二次失败,如图 7.2 所示。因此,以这种方式运行的删除方法不会被视为幂等。但是哪个选项是正确的呢?
On the imperative side, it’s possible to picture the standard delete method as requesting that an action be taken, that action being to remove the resource. Therefore, if the resource does not exist when the request is received, the service is unable to perform the intended action and the request would result in a failure. In other words, attempting to delete the same resource twice in a row might result in a success the first time but a failure the second time, shown in Figure 7.2. As a result, a delete method behaving this way would not be considered idempotent. But which option is right?
Figure 7.2 Imperative view of the standard delete method results in non-idempotent behavior.
虽然有很多声明式 API(例如 Kubernetes),但面向资源的 API 通常在本质上是命令式的。因此,标准的 delete 方法应该以非幂等方式运行。换句话说,尝试删除不存在的资源应该会失败。当您担心网络连接中断和响应丢失时,这可能会导致很多复杂情况,但我们将在第 26 章中更多地探讨 API 请求的可重复性。
While there are lots of declarative APIs out there (e.g., Kubernetes), resource-oriented APIs are generally imperative in nature. As a result, the standard delete method should behave in the non-idempotent manner. In other words, attempting to delete a resource that doesn’t exist should result in a failure. This can lead to lots of complications when you’re worried about network connections getting snapped and responses being lost, but we’ll explore quite a bit more about repeatability for API requests in chapter 26.
最后,让我们看一下与我们实现标准更新方法相关的半标准方法,称为标准代替方法。
Finally, let’s look at a semi-standard method that’s related to our implementation of the standard update method called the standard replace method.
作为我们在 7.3.6 节中了解到,标准更新方法负责修改现有资源的数据。我们还注意到它完全依赖于 HTTPPATCH方法来允许只更新部分资源(我们将在第 8 章中详细了解)。但是,如果您真的想更新整个资源怎么办?标准更新方法可以轻松设置特定字段并控制在资源上设置哪些字段,但这意味着如果您不熟悉某个字段(例如,它是在未来的次要版本中添加的,我们将看到在第 24 章中),资源可能具有您不打算存在的值。
As we learned in section 7.3.6, the standard update method is responsible for modifying the data about existing resources. We also noted that it relied exclusively on the HTTP PATCH method to allow updating only pieces of the resource (which we’ll learn far more about in chapter 8). But what if you actually want to update the entire resource? The standard update method makes it easy to set specific fields and control which fields are set on the resource, but this means that if you’re unfamiliar with a field (e.g., it was added in a future minor version, as we’ll see in chapter 24), it’s possible that the resource will have a value that you don’t intend to exist.
例如,在图 7.3 中我们可以看到消费者更新ChatRoom资源,尝试使用 HTTPPATCH方法设置描述字段。在这种情况下,如果消费者想要删除ChatRoom资源上的所有标签但客户端不知道该tags字段,客户端无法完成此操作!那你怎么处理呢?
For example, in figure 7.3 we can see a consumer updating a ChatRoom resource, attempting to set the description field using the HTTP PATCH method. In this case, if the consumer wanted to erase all the tags on the ChatRoom resource but the client didn’t know about the tags field, there’s no way for the client to accomplish this! So how do you handle it?
Figure 7.3 The standard update method modifies the remote resource but does not guarantee the final content.
半标准替换方法的目标正是:用这个请求中提供的信息替换整个资源。这意味着即使服务有客户端还不知道的附加字段,这些字段也将被删除,因为它们不是由替换资源的请求提供的,如图 7.4 所示。
The semi-standard replace method’s goal is exactly that: replace an entire resource with exactly the information provided in this request. This means that even if the service has additional fields that aren’t known about yet by the client, these fields will be removed given that they weren’t provided by the request to replace the resource, shown in figure 7.4.
Figure 7.4 The standard replace method ensures the remote resource is identical to the request content.
为实现这一点,replace 方法使用 HTTPPUT动词而不是PATCH针对资源本身的动词,并准确(且排他地)提供要存储在资源中的信息。与标准更新方法提供的特定目标修改相比,这种完全替换可确保资源的远程副本看起来与表达的完全一样,这意味着客户无需担心是否存在他们不知道的其他字段。
To accomplish this, the replace method uses the HTTP PUT verb rather than the PATCH verb targeted at the resource itself and provides exactly (and exclusively) the information meant to be stored in the resource. This full replacement, compared to the specific targeted modification provided by the standard update method, ensures that the remote copy of a resource looks exactly as expressed, meaning clients need not worry whether there are additional fields lingering around that they weren’t aware of.
Listing 7.6 Definition of the standard replace method
abstract class ChatRoomApi { @put("/{resource.id=chatRooms/*}") ❶ ReplaceChatRoom(req: ReplaceChatRoomRequest): ChatRoom; ❷ } interface ReplaceChatRoomRequest { resource: ChatRoom; }
❶与标准更新方法不同,替换方法依赖于 HTTP PUT 动词。
❶ Unlike the standard update method, the replace method relies on the HTTP PUT verb.
❷与标准更新方法类似,replace 方法返回新更新(或创建)的资源。
❷ Similar to the standard update method, the replace method returns the newly updated (or created) resource.
这导致了一个可能令人困惑的问题:如果我们可以用我们想要的内容替换资源的内容,我们是否可以使用相同的机制来替换不存在的资源?或者说,这种神奇的替换标准方法真的可以成为创造新资源的工具吗?
This leads to a potentially confusing question: if we can replace the content of a resource with exactly what we want, can’t we just use that same mechanism to replace a non-existing resource? In other words, can this magical replace standard method actually be a tool for creating new resources?
简短的回答是肯定的:此替代工具可用于创建新资源,但它本身不应替代标准创建方法,因为在某些情况下,API 使用者可能希望在(且仅当)创建资源时创建资源资源尚不存在。同样,他们可能希望在(且仅当)资源已经存在时更新资源。使用标准替换方法,无法知道您是在替换(更新)现有资源还是创建新资源,尽管这两种方法最终都取得了成功。相反,您只知道曾经存储在特定资源位置的内容现在设置为发出替换请求时提供的内容。
The short answer is yes: this replacement tool can be used to create new resources, but it should not be a replacement itself for the standard create method because there are cases where API consumers may want to create a resource if (and only if) the resource doesn’t already exist. Similarly, they may want to update a resource if (and only if) the resource already does exist. With the standard replace method, there’s no way to know whether you’re replacing (updating) an existing resource or creating a new one, despite the ultimate success of the method either way. Instead, you only know that what was once stored in the place for a specific resource is now set to the content provided when making the replace request.
此外,您可能会猜到,使用标准替换方法创建资源意味着 API 必须支持用户选择的标识符。正如我们在第 6 章中了解到的,这在某些情况下当然是可能的并且可以接受,但由于各种原因通常应该避免(参见第 6.4.2 节)。
Further, as you might guess, using the standard replace methods to create resources means the API must support user-chosen identifiers. As we learned in chapter 6, this is certainly possible and acceptable in some cases but should generally be avoided for a variety of reasons (see section 6.4.2).
因此,我们探索了关键的标准方法,这些方法将构成大多数面向资源的 API 交互的最大份额。在下一节中,我们将简要回顾一下将它们放在一起时的样子。
And with that, we’ve explored the key standard methods that will make up the lion’s share of the interaction for most resource-oriented APIs. In the next section we’ll briefly review what this looks like when we put it all together.
显示清单 7.7 中是我们目前讨论过的所有标准方法的集合:创建、获取、列表、更新、删除,甚至替换。该示例沿用了之前的示例,其中包含ChatRoom资源和孩子Message 资源秒。
Shown in listing 7.7 is a collection of all the standard methods we’ve talked about so far: create, get, list, update, delete, and even replace. The example follows those from earlier, with ChatRoom resources and child Message resources.
Listing 7.7 Final API definition
abstract class ChatRoomApi { @post("/chatRooms") CreateChatRoom(req: CreateChatRoomRequest): ChatRoom; @get("/{id=chatRooms/*}") GetChatRoom(req: GetChatRoomRequest): ChatRoom; @get("/chatRooms") ListChatRooms(req: ListChatRoomsRequest): ListChatRoomsResponse; @patch("/{resource.id=chatRooms/*}") UpdateChatRoom(req: UpdateChatRoomRequest): ChatRoom; @put("/{resource.id=chatRooms/*}") ReplaceChatRoom(req: ReplaceChatRoomRequest): ChatRoom; @delete("/{id=chatRooms/*}") DeleteChatRoom(req: DeleteChatRoomRequest): void; @post("/{parent=chatRooms/*}/messages") CreateMessage(req: CreateMessageRequest): Message; @get("/{parent=chatRooms/*}/messages") ListMessages(req: ListMessagesRequest): ListMessagesResponse; } interface CreateChatRoomRequest { resource: ChatRoom; } interface GetChatRoomRequest { id: string; } interface ListChatRoomsRequest { parent: string; filter: string; } interface ListChatRoomsResponse { results: ChatRoom[]; } interface UpdateChatRoomRequest { resource: ChatRoom; } interface ReplaceChatRoomRequest { resource: ChatRoom; } interface DeleteChatRoomRequest { id: string; } interface CreateMessageRequest { parent: string; resource: Message; } interface ListMessagesRequest { parent: string; filter: string; } interface ListMessagesResponse { results: Message[]; }
每当你依赖标准而不是完全定制设计的工具,你就放弃了一些灵活性。不幸的是,除非您的场景恰好与标准设计的完全匹配,否则这种权衡有时会很痛苦。但是作为这些不匹配的交换,你几乎总是会得到很多好处。
Whenever you rely on standards rather than entirely custom-designed tooling, you give up some flexibility. And unfortunately, unless your scenario happens to be an exact match for which the standard was designed, this trade-off can occasionally be painful. But in exchange for those mismatches, you almost always get quite a few benefits.
在某些方面,这有点像买衣服。中号的新衬衫可能有点大,但小号肯定太小了,你会觉得你的选择要么是减肥,要么是穿一件有点宽松的衬衫。但好处是这件衬衫的成本约为 10 美元,而不是 100 美元,因为它可以批量生产。这就是我们发现自己在使用面向资源的 API 和标准方法时遇到的情况。
In some ways, it’s a bit like shopping for clothing. That new shirt in size medium might be just a bit too big, but size small is definitely way too small, and you’re left feeling like your options are either to lose weight or have a bit of a baggy shirt. But the benefit is that this shirt costs about $10 instead of $100 because it’s able to be mass produced. This is sort of the scenario we find ourselves in with resource-oriented APIs and standard methods.
这些标准方法并不适用于所有情况。他们会时不时地出现一些不匹配,例如,事物并不是真正被创造出来的,而是更多的是被创造出来的。你完全可以设计一个自定义方法来处理这些独特的场景(见第 9 章)。但是,作为依赖标准方法而不是完全自定义的基于 RPC 的 API 的交换,您可以获得那些使用您的 API 的人的好处,他们能够快速学习和理解不同的方法,而无需做很多工作。更好的是,一旦他们了解了这些方法的工作原理(以防他们在过去使用 RESTful API 时不知道这些方法),他们就会知道这些方法如何在您的 API 中的所有资源中工作,从而有效地成倍增加他们的知识一应俱全。
These standard methods are not perfect for every situation. They will have some mismatches from time to time, for example where things aren’t really created but more brought about into existence. And you could absolutely design a custom method to handle these unique scenarios (see chapter 9). But in exchange for relying on standard methods rather than an entirely custom RPC-based API, you get the benefit of those using your APIs being able to quickly learn and understand the different methods without having to do much work. And even better, once they’ve learned how the methods work (in case they hadn’t already known that from working with RESTful APIs in the past), they know how the methods work across all of the resources in your API, effectively multiplying their knowledge at the drop of a hat.
简而言之,标准方法应该(并且很可能会)在 90% 的情况下获得 API。对于其余场景,您可以在下一章中探索自定义方法。但是使用一组通用构建块的标准化非常有用,因此尝试使用标准方法构建 API 几乎总是最佳选择,并且仅在某些无法预料的情况下才扩展到自定义选项。必要的。
In short, standard methods should (and likely will) get an API 90% of the way there. And for the rest of the scenarios, you have custom methods to explore in the next chapter. But the standardization of using a set of common building blocks is so useful that it’s almost always the best choice to try building the API using standard methods and only expanding to custom options when some unforeseen scenario makes them absolutely necessary.
Is it acceptable to skip the standard create and update methods and instead just rely on the replace method?
What if your storage system isn’t capable of strong consistency of newly created data? What options are available to you to create data without breaking the guidelines for the standard create method?
Why does the standard update method rely on the HTTP PATCH verb rather than PUT?
Imagine a standard get method that also updates a hit counter. Is it idempotent? What about a standard delete method? Is that idempotent?
Why should you avoid including result counts or supporting custom sorting in a standard list method?
Standard methods are a tool to drive more consistency and predictability.
It’s critical that all standard methods follow the same behavioral principles (e.g., all standard create methods should behave the same way).
Idempotency is the characteristic whereby a method can be repeatedly called with identical results on all subsequent invocations.
Not all standard methods must be idempotent, but they should not have side effects, where invoking the method causes changes somewhere else in the API system.
While it might seem counterintuitive, the standard delete method should not be idempotent.
标准方法确实会强制适应一组非常狭窄的行为和特征,但这是换取更容易学习的 API,让用户可以从他们现有的面向资源的知识中获益蜜蜂。
Standard methods do force a tight fit into a very narrow set of behaviors and characteristics, but this is in exchange for a much easier-to-learn API that allows users to benefit from their existing knowledge about resource-oriented APIs.
正如我们在第 7 章中了解到的,重要的是我们有能力以零碎的方式更新资源,而不是总是依赖于完全替换。在此模式中,我们探索使用字段掩码作为一种工具来仅更新我们对给定资源感兴趣的特定字段。此外,我们还介绍了如何将字段掩码应用于相反方向的同一问题:仅检索资源上的特定字段。虽然不太常见,但检索资源的一部分而不是整个资源的能力在内存敏感的应用程序中尤为重要,例如使用 API 输出的物联网设备。
As we learned in chapter 7, it’s important that we have the ability to update resources in piecemeal fashion rather than always relying on full replacement. In this pattern, we explore using field masks as a tool to update only the specific fields we’re interested in for a given resource. Further, we also cover how to apply field masks to the same problem in the opposite direction: retrieving only specific fields on a resource. While slightly less common, the ability to retrieve parts of a resource rather than the entire thing is particularly important in memory-sensitive applications, such as IoT devices consuming API output.
在这种设计模式中,我们实际上将探索同一枚硬币的两面,它们都与资源的不同视图有关。到目前为止,在我们讨论的任何 API 中,我们只将资源称为独立的原子单元。换句话说,我们从未考虑过将资源分解成其构成属性并在更细粒度级别上进行操作的可能性。尽管这使得一些编程范例变得更加简单(例如,您永远不必担心管理资源的一部分),但当您希望在与 API 交互时更具体和表达您的意图时,它可能会成为问题. 让我们看一下此限制所禁止的新功能的两个具体实例。
In this design pattern, we’ll actually be exploring two sides of the same coin, both related to different views of a resource. In any API that we’ve discussed so far, we’ve only ever spoken of resources as self-contained, atomic units. Put differently, we’ve never considered the possibility that a resource could be broken apart into its constituent attributes and manipulated on a more granular level. Despite the fact that this makes some of the programming paradigms much simpler (e.g., you never have to worry about managing part of a resource), it can become problematic when you want to be more specific and expressive about your intent when interacting with the API. Let’s look at two specific instances of new functionality prohibited by this restriction.
在在大多数 API 中,当您检索资源时,您要么得到整个资源,要么得到一个错误,全有或全无。对于我们中的许多人来说,这并不是真正的问题。毕竟,我们还能想要什么?但是,如果此 API 中的资源变得非常庞大,包含数百个不同的字段和子字段怎么办?或者如果发出请求的设备可用的计算资源非常有限,例如在非常小的物联网设备上怎么办?或者如果连接速度受限或非常昂贵怎么办?在这些场景中,准确控制从 API 返回的信息量突然变得非常重要。
In most APIs, when you retrieve a resource you either get the entire resource or an error, all or nothing. For many of us, this isn’t really a problem. After all, what else could we want? But what if the resource in this API grows to be quite large with hundreds of different fields and subfields? Or what if the computing resources available to the device making the request are significantly limited, for example on a very small IoT device? Or what if the connectivity is limited in speed or extraordinarily expensive? In these scenarios, controlling exactly how much information is returned from the API suddenly becomes quite important.
虽然这看起来似乎只有在巨大的资源或非常严格的条件下才需要,但请记住,虽然一个新字段可能只占用一点点空间(因此设备上的成本、带宽和内存更多) ,当列出许多这些资源时,一点点空间就会乘以项目数量。正如您想象的那样,随着资源数量的增加和时间的流逝,曾经很小的数字突然变得非常大。最终,为 API 用户提供仅检索他们真正感兴趣的资源片段(或资源列表)的能力具有很大的价值在。
And while this might seem like something only ever needed for enormous resources or very restrictive conditions, keep in mind that while a single new field might only take up a tiny bit more space (and therefore more cost, bandwidth, and memory on the device), when listing many of these resources that little bit of space is multiplied by the number of items. As you might imagine, suddenly what was once a small number can actually grow quite large as the number of resources grows and time rolls on. Ultimately, there is quite a lot of value in providing the ability for users of an API to retrieve only the pieces of a resource (or list of resources) that they’re truly interested in.
这支持部分更新背后的逻辑有点复杂——而且不太关注性能因素和硬件限制等问题。与其担心阅读资源的特定部分,我们更关心能够对特定字段进行细粒度定位和修改。那么我们究竟为什么要关心这种更具体的更新呢?确保资源看起来完全符合我们的要求而不是一堆小改动不是很重要吗?
The logic behind supporting partial updates is a bit more complicated—and less focused on the performance factors and issues like hardware limitations. Instead of worrying about reading specific pieces of a resource, we’re more concerned with being able to do fine-grained targeting and modification of specific fields. So why exactly would we care about this type of more specific updating? Isn’t it important to ensure a resource looks exactly the way we want rather than a collection of lots of little changes?
首先,存在对数据一致性的担忧。作为 API 的用户,当我们只有标准替换方法(请参阅第 7 章)的粗略画笔时,我们被迫更新资源上的所有字段,而不仅仅是我们感兴趣的字段. 覆盖对我们来说并不重要的字段,如果没有适当的一致性检查可用,可能会导致数据丢失,这显然根本不是一件好事。要了解这是如何发生的,请想象以下代码片段(如表 8.1 所示)由两个不同的客户端针对同一资源运行e.
First, there’s a concern about data consistency. As the user of an API, when we only have the broad brush of a standard replace method (see chapter 7) to paint with, we’re forced to update all the fields on a resource rather than just the one we’re interested in. Overwriting fields that don’t really matter to us, without the proper consistency checks available, can potentially cause data loss, which is obviously not a good thing at all. To see how this might happen, imagine the following code snippets (shown in table 8.1) being run by two separate clients against the same resource.
Table 8.1 An example sequence of code run by two users that could present problems
如果这两个序列在没有任何类型的一致性检查或锁定的情况下执行,最终结果是只有最终的标准替换方法才是重要的。第一个由用户 1 执行的可能根本就没有发生过!通过观察这一点,这两个更新完全没有理由发生冲突。用户 1 想要更新ChatRoom资源的标题,而用户 2 只想更新描述字段,这是完全独立的。即使我们添加了一些一致性检查,第二个标准替换方法也会失败并需要重试以确保更新确实已提交。
If these two sequences were executed without any sort of consistency checks or locking, the end result is that only the final standard replace method matters. The first one, executed by user 1 might as well have never happened at all! And by looking at this, there’s no great reason why these two updates should conflict at all. User 1 wanted to update the title of the ChatRoom resource, whereas user 2 only wanted to update the description field, which was completely separate. And even if we added in some consistency checks, the second standard replace method would fail and require a retry to ensure that the update was actually committed.
但是这个问题还有更多,导致数据丢失的方法也更多。正如我们将在第 24 章中更详细地看到的那样,API 很少保持不变。相反,它们往往会随着时间的推移而发展,以支持新功能并修复现有功能中的错误。我们最终依赖的最常见场景之一是能够向资源添加新字段,同时仍然考虑新版本向后兼容。但是,如果您更新资源的唯一机制是完全替换它们,并且您的本地接口没有与资源上的完整(新)字段列表保持同步,那么这可能会带来很大的问题。
But there’s more to this problem, and more ways to cause data loss. As we’ll see in more detail in chapter 24, APIs rarely stay the same. Instead, they tend to evolve over time to support new features and fix bugs in existing ones. And one of the most common scenarios we end up relying on is the ability to add new fields to resources while still considering the new version backward compatible. But this can present quite a problem if your only mechanism to update resources is to replace them entirely and your local interfaces are not up to date with the complete (new) list of fields on a resource.
例如,假设我们想要更新ChatRoom资源的标题,但是在我们上次更新客户端库之后(当我们存储在ChatRoom资源上的唯一字段是标题字段和管理员时),服务添加了一个新的描述字段。在这个世界上,如果之前有人为资源设置了描述,我们的标准替换方法可能最终会破坏该数据,我们最终会再次丢失数据。ChatRoom清单 8.1 显示了两个不同的客户端更新资源,看看这是什么样子的。首先,代码是最新的并且很清楚新的描述字段。后者,代码好久没更新了,没听说过这个描述字段d.
For example, let’s imagine that we want to update the title of a ChatRoom resource, but in the time since we last updated our client library (when the only field we stored on the ChatRoom resource was a title field and the administrator), the service added a new description field. In this world, if someone previously set a description for the resource, it’s possible that our standard replace method will end up clobbering that data and we end up, yet again, with data loss. To see what this looks like, listing 8.1 shows two different clients updating a ChatRoom resource. In the first, the code is up-to-date and is well aware of the new description field. In the latter, the code hasn’t been updated in a while and has never heard of this description field.
Listing 8.1 Two different clients updating a single resource
> let chatRoom = latestClient.GetChatRoom({ id: '1' }); ❶ > chatRoom.description = 'Description!'; > ReplaceChatRoom({ chatRoom: chatRoom }); > console.log(chatRoom); { id: '1', title: 'Old title', description: 'description' } > chatRoom = oldClient.GetChatRoom({ id: '1' }); ❷ > console.log(chatRoom); { id: '1', title: 'Old title', } > chatRoom.title = 'New title'; ❸ > ReplaceChatRoom({ chatRoom: chatRoom }); > chatRoom = latestClient.GetChatRoom({ id: '1' }); ❹ > console.log(chatRoom); { id: '1', title: 'New title', description: null }
❶ First, we use the “latest client” to update the description of the room.
❷ If we use the old client to fetch the resource, the description field is entirely missing.
❸ We then update the title with the old client without any issues.
❹ However, now when we use the new client again, the description field has been erased!
虽然这看起来很可怕,好像出了什么问题,但事实是标准的替换方法正在做它应该做的事情。回想一下,此方法专门设计用于使远程资源看起来与请求中指定的资源完全一样。这意味着从任何缺失的字段中删除数据,无论这是否是我们的意图。
While this might look scary and as though something is wrong, the truth is that the standard replace method is doing exactly what it’s supposed to do. Recall that this method is designed specifically to make the remote resource look exactly like the resource specified in the request. That means removing the data from any fields that are missing, whether or not that was our intent.
简而言之,部分更新的目标是让 API 消费者更具体地了解他们的意图。如果他们打算替换整个资源,他们可以使用标准替换方法。如果他们打算只更新一个字段,则应该有一个更细粒度的机制来表达更新资源的意图。在这种情况下,部分更新是很好的解决方案。
In short, the goal of partial updates is to allow API consumers to be more specific about their intent. If they intend to replace the entire resource, they have the standard replace method at their disposal. If they intend to update only a single field, there should be a more fine-grained mechanism by which they can express this intent to update the resource. In this case, partial updates are a great solution.
到完成这两个目标(启用部分检索和部分更新),我们实际上可以依赖一个工具:字段掩码。从根本上说,字段掩码只是字符串的集合,但这些字符串表示我们对给定资源感兴趣的字段列表。当需要检索资源并且我们想要更具体地说明我们想要检索哪些字段时,我们可以简单地提供表示应返回哪些字段的字段掩码,如图 8.1 所示。
To accomplish both of these goals (enabling partial retrievals and partial updates), we can actually rely on a single tool: the field mask. Fundamentally, a field mask is just a collection of strings, but these strings represent a list of fields that we’re interested in on a given resource. When it comes time to retrieve a resource and we want to be more specific about which fields we’d like to retrieve, we can simply provide the field mask expressing which fields should be returned, shown in figure 8.1.
Figure 8.1 Using a field mask when retrieving a resource
正如我们可以使用字段掩码来控制我们有兴趣检索哪些字段一样,我们也可以依靠相同的工具来控制服务应该在资源上更新哪些字段。在这种情况下,当更新资源时,我们可以提供我们打算更新的字段列表,并确保只有那些特定的字段被修改,如图 8.2 所示。
And just as we can use a field mask to control which fields we’re interested in retrieving, we can also rely on the same tool to control which fields a service should update on a resource. In this case, when updating a resource we can provide the list of fields we intend to update and ensure that only those specific fields are modified, shown in figure 8.2.
Figure 8.2 Replacing an entire resource versus updating a single field
此外,由于 JSON 恰好是一种动态数据结构,如果PATCH请求中缺少字段掩码本身,我们可以从 JSON 对象中存在的属性推断出字段掩码(如图 8.3 所示)。虽然在我们的 API 中处理动态数据结构时这会变得更加复杂,但在大多数情况下,此字段掩码推断提供了最预期的结果。
Further, since JSON happens to be a dynamic data structure, if the field mask itself is missing from a PATCH request, we can infer the field mask from the attributes present in the JSON object (shown in figure 8.3). While this can become far more complicated when dealing with dynamic data structures in our API, in most cases this field mask inference provides the most expected results.
图 8.3 使用隐式字段掩码更新单个字段,根据提供的数据推断
Figure 8.3 Updating a single field using an implicit field mask, inferred from the data provided
这可能看起来很简单,但有相当多的边缘案例和棘手的场景远比看起来复杂得多。在下一节中,我们将探讨如何在一个应用程序接口。
This might seem simple, but there are quite a few edge cases and tricky scenarios that are far more complicated than they might seem. In the next section, we’ll explore how to go about implementing support for field masks in an API.
现在我们掌握了场掩模的高级概念,我们需要更详细地了解它们的工作原理。换句话说,我们知道我们应该能够在GETorPATCH请求上发送这个任意字段列表,结果是更具体的更新或检索。但是我们如何发送这些字段呢?毕竟,GET请求不接受主体,PATCH请求应该将资源本身作为请求主体。让我们更深入地研究如何在不对我们在第 7 章中定义的标准请求造成任何重大干扰的情况下传输字段掩码。
Now that we have a grasp on the high-level concept of field masks, we need to look at how they work in more detail. In other words, we know that we should be able to send this list of arbitrary fields on GET or PATCH requests and the result is a more specific update or retrieval. But how do we go about sending those fields? After all, a GET request doesn’t accept a body, and a PATCH request should have the resource itself as the body of the request. Let’s dig deeper into how we transport the field mask without causing any significant disruption to the standard requests that we defined in chapter 7.
尽管字段掩码当然看起来非常强大,它们引出了一个重要的问题:我们如何在我们的请求中将它们传输到 API 服务器?考虑到我们与 HTTP 的密切关系,我们如何在遵守 HTTP 规则和面向资源的 API 设计的同时做到这一点?由于对GET和PATCH请求的两个重要约束,这变得特别复杂。
While field masks certainly appear very powerful, they lead to an important question: how do we transmit them to the API server in our request? And given our close relationship with HTTP, how do we do so while still obeying the rules of HTTP and resource-oriented API design? This becomes particularly complicated because of two important constraints on GET and PATCH requests.
首先,对于GET请求,没有允许的请求主体(如果提供的话,许多 HTTP 服务器会将其删除)。这意味着我们绝对不能使用 HTTP 请求主体来指示我们有兴趣检索的字段。接下来,对于PATCH请求,即使主体显然是允许的(这就是我们更新资源本身的方式),面向资源的设计规定PATCH请求的主体必须是正在更新的资源表示。换句话说,虽然我们在技术上可以在单个 JSON 表示中同时提供字段和资源,但这会破坏标准更新方法的许多基本假设,并导致各种不一致。
First, for GET requests, there is no body to the request permitted (and many HTTP servers will strip it out if one is provided). This means that we definitely cannot use the HTTP request body to indicate the fields we’re interested in retrieving. Next, for PATCH requests, even though a body is obviously permitted (that’s how we go about updating the resource itself), resource-oriented design dictates that the body of a PATCH request must be the resource representation being updated. In other words, while we technically could provide the field and the resource together in a single JSON representation, this would break quite a few of the fundamental assumptions of the standard update method and lead to all sorts of inconsistencies down the line.
清单 8.2 打破 HTTP PATCH 方法规则导致的部分更新混乱
Listing 8.2 Partial updates confusion caused by breaking HTTP PATCH method rules
PATCH /chatRooms/1 HTTP/1.1 Content-Type: application/json { "chatRoom": { ❶ "id": 1, "title": "Cool Chat" }, "fieldMask": ["title"] ❷ }
❶本例中的 ChatRoom 资源不是请求的主体,违反了标准更新方法的准则。
❶ The ChatRoom resource in this case is not the body of the request, breaking the guidelines for a standard update method.
❷ FieldMask 本身位于 ChatRoom 资源旁边。
❷ The FieldMask itself is alongside the ChatRoom resource.
我们可以做什么?除了我们的字段掩码的 HTTP 请求正文之外,还有两个可能的地方:标头和查询字符串。虽然这两种技术在技术上都有效,但事实证明查询参数更容易访问,特别是考虑到我们甚至可以在浏览器请求中修改这些参数,而标头在 HTTP 管道中隐藏得更深,使它们更难访问. 因此,查询参数可能是更好的选择。
What can we do? There are two potential places besides the HTTP request body for our field masks: headers and query strings. While both of these technically work, it turns out that query parameters are quite a bit more accessible, particularly given that we can even modify these in a browser request, whereas headers are buried a bit deeper in the plumbing of HTTP, making them less accessible. As a result, query parameters are probably a better choice.
不幸的是,似乎没有针对重复查询参数的规范,这意味着如何解释重复查询字符串参数将取决于所使用的 HTTP 服务器。例如,考虑表 8.2 中的示例。如您所见,不同的服务器以不同方式处理这些输入,因此依赖于与多次使用同一字段的最常见标准一致的库非常重要,例如?fieldMask=title&fieldMask=description. 尽管备选方案之一 ( fieldMask=a,b) 可能看起来更简洁,但当我们需要表 8.2 来探索映射或嵌套接口时,这可能会出现问题(请参阅第 8.3.5 节).
Unfortunately, there appears to be no specification for repeated query parameters, meaning how the repeated query string parameters are interpreted will depend on the HTTP server in use. For instance, consider the examples in table 8.2. As you can see, different servers handle these inputs differently, so it’s important to rely on a library that will be consistent with the most common standard of using the same field multiple times, such as ?fieldMask=title&fieldMask=description. Even though one of the alternatives (fieldMask=a,b) might seem more concise, this can present issues when we needTable 8.2 to explore maps or nested interfaces (see section 8.3.5).
Table 8.2 Examples of how different systems handle multi-value query parameters
这意味着我们必须增加标准更新方法和标准获取方法的请求消息。如您所见,这只是添加一个新fieldMask属性的问题在请求消息上。
This means that we have to augment the request messages for both the standard update method and standard get method. As you can see, this is just a matter of adding a new fieldMask attribute on the request messages.
Listing 8.3 Example of standard get and update requests with field masks included
type FieldMask = string[]; ❶ interface GetChatRoomRequest { id: string; fieldMask: FieldMask; ❷ } interface UpdateChatRoomRequest { resource: ChatRoom; fieldMask: FieldMask; ❷ }
❶ A FieldMask type is nothing more than an array of paths.
❷我们可以简单的在update和get请求接口中增加一个fieldMask属性。
❷ We can simply add a fieldMask property to the update and get request interfaces.
现在我们知道如何在我们的 API 中传输这些字段掩码值(同时仍然遵守 HTTP 和面向资源的 API 标准),让我们更仔细地看看每个字段掩码条目包含的值,从映射和嵌套开始接口。
Now that we know how to transport these field mask values in our API (while still adhering to HTTP and resource-oriented API standards), let’s look more closely at the values each field mask entry contains, starting with maps and nested interfaces.
所以到目前为止,我们实际上只考虑了平面资源数据结构——也就是说,那些没有任何嵌套值类型的数据结构。尽管我们可能喜欢这个世界的简单性,但它不一定反映现实,我们对资源具有价值,而这些资源本身就是其他接口类型。此外,我们可能会发现自己处于资源包含映射类型的字段的场景中,它只是键值对的集合。这将我们引向一个有趣的难题:这种部分更新和检索的想法是否扩展到嵌套结构内部?或者它只适用于最顶层,有效地将资源视为完全扁平的结构?
So far, we’ve really only thought about flat resource data structures—that is, those with no nested value types whatsoever. As much as we might love the simplicity of that world, it’s not necessarily reflective of reality, where we have values on resources that, themselves, are other interface types. Additionally, we may find ourselves in the scenario where a resource contains a field that is a map type, which is simply a collection of key-value pairs. This leads us to an interesting conundrum: does this idea of partial updates and retrievals extend inside nested structures? Or does it only apply at the very top level, effectively treating resources as completely flat structures?
好消息是,肯定有一种方法允许使用字段掩码指向嵌套字段(在嵌套静态接口和动态映射类值中)。坏消息是,这需要在字段掩码条目本身中有相当多的特异性和特殊转义字符。要了解其工作原理,让我们从一个简单的规则列表开始,我们可以将这些规则组合成一个强大的工具箱来处理大多数情况。别担心,我们稍后会查看示例。
The good news is that there is definitely a way to allow pointing to nested fields with field masks (both in nested static interfaces and in dynamic map-like values). The bad news is that this requires quite a lot of specificity and special escape characters in the field mask entries themselves. To see how this works, let’s start with a simple list of rules that we can assemble into a powerful toolbox to address most scenarios. And don’t worry, we’ll look at examples in a moment.
Separate parts of a field specification must use a dot character (.) as a separator.
All fields of a nested message may be referred to using an asterisk character (*).
All parts of a field specification (field names or map keys) that can’t be represented as an unquoted string literal must be quoted using backtick characters (`).
A literal backtick character may be escaped by using two backtick characters (``).
如果这些规则让你感到害怕,那就坚持下去。这些示例将使它更清晰。为了探索这个独特的问题空间,让我们从一个示例开始,如清单 8.4 所示。在这种情况下,假设我们的ChatRoom资源有两个LoggingConfig字段以及settings地图字段可能包含任意键值样式数据。让我们看看我们如何着手解决各个领域的问题秒。
If these rules have you terrified, just hang in there. The examples will make it much cleaner. To explore this unique problem space, let’s start with an example, shown in listing 8.4. In this case, let’s imagine our ChatRoom resource has both a LoggingConfig field as well as settings map field that might contain arbitrary key-value style data. Le’s look at how we might go about addressing each of the various fields.
Listing 8.4 Adding a nested interface field and a dynamic map field
interface ChatRoom { id: string; title: string; description: string; loggingConfig: LoggingConfig; ❶ settings: Object; ❷ } interface LoggingConfig { maxSizeMb: number; maxMessageCount: number; }
❶这里我们有一个嵌套字段,尽管它是一个具有明确定义字段的静态结构。
❶ Here we have a nested field, though it is a static structure with well-defined fields.
❷ The settings field is an arbitrary key-value map, with values of varying types.
为了可视化我们如何应用这些规则中的每一个,表 8.3 显示了ChatRoom资源中数据的示例表示,以及我们如何将单个字段作为字段 mas 中的条目进行处理的示例k.
To visualize how we might apply each of these rules, table 8.3 shows an example representation of the data in a ChatRoom resource, with an example of how we might address that single field as an entry in a field mask.
Table 8.3 A resource representation with corresponding field mask entries for each field
如您所见,有一种方法可以使用规则来处理资源中的每个单独字段。虽然大多数都非常简单(例如,"description"和"settings .test.value",但其他的特别难看,乍一看可能会造成混淆。例如,在映射键可能被解释为数值的情况下,我们需要在反引号中引用它们。情况也是如此其中点字符可能会被误解为分隔符文字字符(例如"settings.test .value"and的情况"settings.`test.value`"很容易混淆)。最后,如果反引号需要用作文字字符,则应该简单地加倍(无论您需要什么`,只要使用``)。
As you can see, there’s a way to address each individual field in the resource using the rules. While most are pretty straightforward (e.g., "description" and "settings .test.value", others are particularly ugly and might be confusing at first glance. For example, in cases where map keys might be interpreted as numeric values, we need to quote these in backticks. The same goes for cases where a dot character might be misinterpreted for a separator literal character (such as the case of "settings.test .value" and "settings.`test.value`" which could be easily confused). Finally, if backticks need to be used as literal characters, these should simply be doubled (wherever you need a `, just use ``).
通过遵循定义字段掩码格式的这五个规则,我们应该能够处理数据结构中的任何嵌套字段,即使它具有奇怪的字符文字(如点、星号或反引号)。
By following these five rules for defining the format of a field mask, we should be able to address any nested field in a data structure, even if it has strange character literals (like dots, asterisks, or backticks).
但是,这确实遗漏了资源中一个重要的字段类型类别:重复字段或数组。在下一节中,我们将探索如何使用 field 解决资源中的重复字段面具。
However, this does leave out an important category of field types in resources: repeated fields or arrays. In the next section, we’ll explore how we might address repeated fields in resources using field masks.
我们有一个清晰简单的方法来解决嵌套接口和映射字段中的字段,但是重复的字段呢,比如字符串列表?如果重复字段本身是嵌套接口怎么办?我们如何处理给定书籍的所有作者的姓氏?幸运的是,有一种明确的方法可以做到这一点。但在此之前,必须首先讨论一个重要的限制:通过索引对重复字段中的项目进行寻址。
We have a clear and simple way to address fields in nested interfaces and map fields, but what about repeated fields, such as lists of strings? What if the repeated field is a nested interface itself? How do we go about addressing the last name of all the authors of a given book? Luckily, there is a clear way to do this. But before we get to that, there’s an important limitation that must be discussed first: addressing items in a repeated field by their index.
我们都熟悉我们如何在我们选择的编程语言中处理数组中的单个项目。几乎总是,这类似于item[0]获取名为 item 的数组中的第一项。虽然这显然是编程语言中的一项重要功能,但它在 Web API 中真的有意义吗?
We’re all familiar with how we address a single item in an array in our programming language of choice. Almost always, this is something like item[0] to get the first item in an array called item. While this is obviously a critical piece of functionality in a programming language, does it really make sense in a web API?
如果我们碰巧是该 API 的唯一用户,或者我们正在寻址的资源是完全不可变的,并且索引是唯一标识符或排序,那么也许。但在几乎所有其他情况下,Web API 中的此功能可能会以多种方式产生误导。
If we happen to be the only user of that API, or the resource we’re addressing is completely immutable, and the index is a unique identifier or sorts, then maybe. But in almost every other scenario, this functionality in a web API can be misleading in more ways than one.
首先,通过索引访问项目意味着该索引是唯一标识符,而在大多数情况下实际上并非如此。索引现在是一个标识符,但它不是一个稳定的标识符,因为它很容易随着时间的推移而改变,无论是通过在有问题的项目之前插入一个项目,还是通过替换整个数组值。接下来,通过使用索引作为任何类型的标识符,可以清楚地暗示这个特定列表的稳定排序。这意味着列表中项目的顺序保证随着时间的推移保持一致,即使新项目附加到列表末尾也是如此。这些看似微不足道的影响,但随着时间的推移维护它们可能会成为一种负担,而且没有太多好处。
First, accessing an item by its index implies that this index is a unique identifier, when in most cases this is actually not the case. The index is an identifier now, but it’s not a stable identifier in that it could very easily change over time, either by inserting an item before the item in question or by replacing the entire array value. Next, by using an index as an identifier of any sort, there’s a clear implication of a stable ordering of this particular list. This means that the order of items in the list is guaranteed to remain consistent over time, even as new items are appended to the end of the list. These might seem like small implications, but maintaining them over time can become quite a burden, one that comes without much benefit.
由于所有这些问题,能够通过 Web API 中的索引单独处理列表项确实没有多大意义。如果确实需要这种功能,那么依赖地图字段(其项目具有稳定、真实、本地唯一标识符)或子资源集合(每个子资源都有自己的全局唯一标识符)会更有用。因此,重要的是 API不支持根据索引检索或更新重复字段中的单个项目的能力。换句话说,消费者永远不应该能够指示 API 在资源的重复字段中更新或检索“索引 0 处的项目”。第一项的想法是完全没有意义的,除非 API 保证各种额外的功能,随着时间的推移维护起来会非常繁重。
Due to all of these issues, it really doesn’t make much sense to be able to address items of a list individually by their index in a web API. If this sort of functionality is truly required, it’s far more useful to rely on a map field (which has stable, true, local unique identifiers for its items) or a collection of sub-resources, each one having their own global unique identifier. And as a result, it’s important that APIs do not support the ability to either retrieve or update a single item in a repeated field based on its index. In other words, a consumer should never have the ability to instruct the API to update or retrieve “the item at index 0” in a repeated field on a resource. This idea of the first item is completely meaningless unless the API guarantees all sorts of extra functionality that is incredibly burdensome to maintain over time.
幸运的是,这并不意味着我们可以与重复字段中的项目完全没有交互。相反,我们可能想对这些字段做一件非常有用的事情,为了实现这一点,我们当然需要一种表达该意图的方法。
Fortunately, this does not mean that we can have no interaction at all with items in repeated fields. On the contrary, there is one very useful thing we may want to do with these fields, and to accomplish this we’ll certainly need a way to express that intent.
假设我们的ChatRoom资源包含类型为 的管理员字段User。我们已经了解了如何只获取管理员的姓名(例如,fieldMask=admin .name),但是如果有多个管理员怎么办?我们怎样才能得到每个管理员的名字?
Let’s imagine that our ChatRoom resource includes an administrator field of type User. We’ve learned how to get just the administrator’s name (e.g., fieldMask=admin .name), but what if there are multiple administrators? How can we get just the names of each administrator?
Listing 8.5 Representation of a ChatRoom with multiple administrators
interface ChatRoom { id: string; title: string; description: string; administrators: User[]; ❶ } interface User { name: string; email: string; // ... }
❶在这种情况下,administrators 是用户界面的重复字段。
❶ In this case, administrators is a repeated field of User interfaces.
在这种情况下,我们可以再次依靠星号来表示相当于for-each循环各种各样的。换句话说,我们可以将前缀 of"administrators.*."视为“对于每个管理员,仅提供列出的字段”的一种表达方式。在这种情况下,要仅检索管理员的姓名,我们可以使用字段掩码值"administrators.*.name"。但是,请记住,此附加功能并不妨碍我们使用管理员的(简单)字段掩码来询问所有管理员。
In this case we can rely, once again, on the asterisk to indicate the equivalent of a for-each loop of sorts. In other words, we can treat a prefix of "administrators.*." as a way of saying “For each administrator, provide only the fields listed.” In this case, to retrieve only the name of an administrator, we could use a field mask value of "administrators.*.name". However, remember that this additional function does not preclude us from asking for all of the administrators using the (simple) field mask of administrators.
不幸的是,虽然这种能力确实使我们能够在重复字段中跨项目选择特定字段,但它并不能使我们更新这些字段。原因与无法解决的原因相同"administrators[0]":重复字段不能保证稳定,因此无法知道列表中的哪个项目打算用哪个值更新。
Unfortunately, while this ability does enable us to select specific fields across items in a repeated field, it does not enable us to update these fields. The reason is based on the same reasons there is no way to address "administrators[0]": the repeated field is not guaranteed to be stable, and it’s therefore impossible to know which item in the list is intended to be updated with which value.
Listing 8.6 A (bad) example of attempting to update items in a repeated field by index
PATCH /chatRooms/1?fieldMask=administrators.*.name HTTP/1.1 Content-Type: application/json { "administrators": [ { "name": "New name for Index 0" }, ❶ { "name": "New name for Index 1" } ] }
❶在没有强一致性和顺序保证的情况下,不能保证这会取代我们感兴趣的管理员。
❶ There’s no guarantee that this will replace the administrator we’re interested in without strong consistency and ordering guarantees.
如您所见,如果无法保证顺序或添加了新管理员并移动了索引,则无法确定结果是否符合我们的预期。我们不是更新重复字段中的单个项目,而是必须对所有重复字段执行完全替换领域。
As you can see, if the order was not guaranteed or a new administrator was added and shifted the indexes around, there would be no certainty that the result would be as we intended. Rather than updating individual items in a repeated field, we instead must perform a full replacement on all repeated fields.
作为我们在第 5 章中了解到,默认值的目标是为用户做“正确的事”。这是因为留空是 API 用户表达他们不一定对 API 的这方面有意见并且相信 API 会为他们提供最适合大多数其他用户的行为的方式。对于字段掩码,所使用的标准方法(标准获取与标准更新)的默认值仅略有不同。
As we learned in chapter 5, the goal of a default value is to do “the right thing” for the user. This is because leaving something blank is the API user’s way of expressing that they don’t necessarily have an opinion on this aspect of the API and are trusting the API to provide them with behavior that best suits most other users. In the case of field masks, the defaults differ only slightly for the standard method being used (standard get versus standard update).
在标准的 get 方法中,默认值几乎总是资源上可用字段的完整列表。这意味着除非指定字段掩码,否则应返回资源接口上存在的每个字段。这确保了支持部分检索的标准 get 方法的行为方式与完全不支持部分检索的方式相同(我们将在 8.4.1 节中更详细地探讨这一点)。
In a standard get method, the default is almost always the complete list of fields available on a resource. That means that every field present on the interface for a resource should be returned unless a field mask is specified. This ensures that the standard get method that does support partial retrieval behaves in the same way as if partial retrieval wasn’t supported at all (we explore this in more detail in section 8.4.1).
该指南有一个例外。如果资源的字段出于某种原因会导致 API 消费者的用户体验从根本上变差,则应从未设置字段掩码的默认值中删除这些字段。例如,假设某个资源包含的字段特别大,或者需要很长时间才能计算出标准的 get 方法需要几分钟才能返回任何结果。在这种情况下,从返回的默认字段集中排除这些有问题的字段可能更有意义。这意味着对这些字段感兴趣的用户需要通过明确指定字段掩码来表达他们的兴趣。
There is an exception to this guideline. In cases where a resource has fields which, for whatever reason, would cause a fundamentally worse user experience for API consumers, these fields should be removed from the default of the field mask being left unset. For example, let’s imagine that a resource contains fields that are exceptionally large or would take so long to compute that the standard get method would take several minutes to return any results. In cases like this, it might make more sense to exclude these problematic fields from the default set of fields returned. This would mean that users who are interested in these fields would need to express their interest by explicitly specifying a field mask.
这种情况应该相对少见,但关键是,如果某个字段确实属于这种例外情况(即,默认情况下不会包含它并且必须明确请求),则字段文档本身必须包含该事实。如果文档中没有该指示,可能会导致极其混乱的行为,并迫使 API 使用者只有在反复试验后才能发现这一事实,这当然不理想。
This scenario should be relatively uncommon, but it’s critical that if a field does fall under this exception (that is, it will not be included by default and must be explicitly requested), the field documentation itself must include that fact. Without that indication somewhere in the documentation, it could lead to exceedingly confusing behavior and force API consumers to discover this fact only after trial and error, which is certainly not ideal.
这导致了最后一个值得考虑的场景:如果我们需要检索所有字段,但我们不想列出每一个字段怎么办?换句话说,我们不想遵循让 API 决定我们应该猜测哪些字段的默认设置。相反,我们想专门询问每个可用的字段,包括那些可能很大或难以计算的字段。这也可能包括自我们上次更新客户端代码以来添加的字段(请参阅第 24 章)。我们应该怎么做?
This leads to one last scenario worth considering: what if we need to retrieve all the fields, but we’d rather not list each and every one of these? In other words, we don’t want to follow the default of letting the API decide what fields we should guess. Instead, we want to specifically ask for every single field available, including those that might be large or difficult to compute. This also might include fields that have been added since we last updated our client code (see chapter 24). How can we do this?
答案其实很简单明了:我们应该依靠一个特殊的哨兵值来指示“一切”;在这种情况下,该值是一个星号 ( "*")。如果此值出现在字段掩码中,则应返回所有字段,无论是否还存在任何其他字段。
The answer is actually pretty simple and straightforward: we should rely on a special sentinel value to indicate “everything”; in this case, that value is an asterisk ("*"). If this value is present in a field mask, all fields should be returned, regardless of whether any other fields are present as well.
现在我们已经探索了部分数据检索的默认值,我们需要稍微切换一下,以决定通过标准更新方法部分更新数据的默认值应该是多少。不幸的是,当涉及到更新资源时,推荐用于检索的所有字段的默认值并没有多大意义;毕竟,如果我们默认提供所有字段,那么它更像是标准的替换方法而不是标准的更新方法。我们应该做什么?
Now that we’ve explored default values for partial retrievals of data, we need to switch gears a bit to decide what the default should be for partial updates of data via the standard update method. Unfortunately, when it comes to updating resources the default of all fields recommended for retrieval doesn’t quite make sense; after all, if we provide all fields by default, then it’s really more like a standard replace method than a standard update method. What should we do?
一种方便的选择是尝试根据提供的数据推断字段掩码。换句话说,我们可以遍历输入数据,并且只更新具有指定值的字段。在下一节中,我们将更详细地探讨这个想法细节。
One convenient choice is to attempt to infer a field mask based on the data provided. In other words, we can go through the input data, and we only update fields if they have a value specified. In the next section, we’ll explore this idea in more detail.
不像检索数据的默认值,更新数据需要更多的思考和努力。这是因为如果我们遵循相同的规则(默认为所有字段),我们最终会得到一个标准的更新方法,它实际上表现得像一个标准的替换方法(因为它必须替换所有字段)。这引出了一个重要的问题:未设置字段掩码对标准更新方法(使用 HTTPPATCH方法)意味着什么)?
Unlike the default value for retrieving data, updating data requires a bit more thought and effort. This is because if we were to follow the same rules (default to all fields), we’d end up with a standard update method that actually behaves like a standard replace method (since it has to replace all fields). This leads us to an important question: what does an unset field mask mean on a standard update method (using an HTTP PATCH method)?
最常见的是,HTTPPATCH指示仅使用请求正文中提供的数据更新资源的意图。这给我们带来了一个有趣的选项,即标准更新方法中字段掩码的默认值:从提供的数据中推断字段掩码。虽然这个策略看起来简单明了,但这个推论却非常强大。如此强大,事实上,它可能是 API 用户真正从字段掩码中获益的主要方式。我们究竟如何做到这一点?
Most commonly, HTTP PATCH indicates an intent to update the resource with only the data provided in the body of the request. This brings us to an interesting option for the default value of a field mask in a standard update method: infer the field mask from the data provided. While this strategy might seem simple and obvious, this inference is incredibly powerful. So powerful, in fact, that it’s likely to be the primary way in which users of an API actually benefit from field masks. How do we do this exactly?
此推理通过遍历提供的所有字段并跟踪到该字段的路径来工作。如果字段本身具有指定的值(在 JSON 或 TypeScript 中,这意味着该值不是未定义的),那么我们将该字段路径包含在最终的推断字段掩码中。
This inference works by iterating through all the fields provided and keeping track of the path to that field. If the field itself has a value specified (in JSON or TypeScript this means that the value is not undefined), then we include that field path in the final inferred field mask.
Listing 8.7 Function to infer a field mask from a resource object
type FieldMask = string[]; ❶ function inferFieldMask(resource: Object): FieldMask { const fieldMask: FieldMask = []; for (const [key, value] of Object.entries(resource)) { ❷ if (value instanceof Object) { for (const field of inferFieldMask(value)) { ❸ fieldMask.push(`${key}.${field}`); } } else if (value !== undefined) { ❹ fieldMask.push(key); } } return fieldMask; }
❶我们首先将 FieldMask 类型定义为字符串字段名称数组。
❶ We begin by defining a FieldMask type as an array of string field names.
❷ We then iterate through all the key-value pairs of the resource object provided.
❸我们使用递归来查找嵌套对象中的字段,并简单地在原始字段前加上一个“ .”字符作为分隔符。
❸ We use recursion to find the fields in the nested object and simply prepend the original field with a "." character as a separator.
❹如果值被设置(包括 null),我们认为它是字段掩码的隐含部分。
❹ If the value is set at all (including null), we consider it implicitly part of the field mask.
使用清单 8.7 中的代码,我们可以简单地通过查看提供的数据来推断用户的意图。换句话说,如果用户打算只更新单个字段,他们可以通过在请求正文中仅提供该字段来暗示(例如,PATCH /chatRooms/1 { "description": "New description" }),我们可以推断他们打算使用该单个字段(["description"])作为场掩码。
Using the code in listing 8.7, we can infer the user’s intentions simply by looking at the data provided. In other words, if a user intends to only update a single field, they can imply that by simply providing only that field in the body of the request (e.g., PATCH /chatRooms/1 { "description": "New description" }) and we can infer that they meant to use that single field (["description"]) as the field mask.
重要的是要注意这里的价值null与在请求正文中保留字段不同。换句话说,虽然undefinedJavaScript 中的值或 JSON 中的缺失字段确实会被排除在推断的字段掩码之外,但值null实际上会显示在字段掩码中。因此,将字段设置为null(例如,PATCH /chatRooms/1 { "description": null })将导致与我们之前的示例()相同的字段掩码,["description"]并且类似地,最终将描述字段的值设置为指定的值,在这种情况下, 将是一个显式null值。
It’s important to note here that a value of null is not the same as leaving a field out of the request body. In other words, while a value of undefined in JavaScript or a missing field in JSON would indeed be left out of the inferred field mask, a value of null would actually show up in the field mask. As a result, setting a field to null (e.g., PATCH /chatRooms/1 { "description": null }) would result in the same field mask as our previous example (["description"]) and, similarly, end up with the value for the description field being set to the value specified, which, in this case, would be an explicit null value.
这通常是一种完全可以接受的行为模式,但是当我们开始处理动态数据结构时,它可能会引发问题,在动态数据结构中,资源上可用的字段实际上可能会随时间变化。让我们花些时间了解如何最好地处理动态数据结构及其带来的独特行为关于。
This is usually a perfectly acceptable pattern of behavior, but it can raise problems when we begin dealing with dynamic data structures, where the fields available on a resource can actually vary over time. Let’s spend some time understanding how we can best handle dynamic data structures and the unique behavior they bring about.
作为我们在第 5 章中了解到,静态数据结构几乎总是更容易处理。这主要是因为它们只有两个不同的字段值类别:实际值(例如,ChatRoom({ title: "Cool chat!" }))和null值(例如,ChatRoom({ title: null }))。另一方面,由于字段本身可能存在也可能不存在,动态数据结构(例如地图)具有第三类:undefined. 例如,正如我们在 8.3.2 节中看到的,设置字段是属于此类的地图。键(例如"test")可以是值、显式null或完全从映射中丢失(在 JavaScript 中被认为等同于undefined)。
As we learned in chapter 5, static data structures are almost always easier to deal with. This is primarily due to the fact that they have only two different categories for values of fields: an actual value (e.g., ChatRoom({ title: "Cool chat!" })) and a null value (e.g., ChatRoom({ title: null })). On the other hand, dynamic data structures (such as maps) have a third category due to the fact that fields themselves may or may not be present: undefined. For example, as we saw in section 8.3.2, the settings field was a map that falls under this category. A key (e.g., "test") could be either a value, an explicit null, or missing from the map entirely (which in JavaScript is considered equivalent to undefined).
这导致了一个棘手的情况。假设设置字段设置为{ "test": "value" }。要更改值,我们可以依赖隐式字段掩码:PATCH /chatRooms/1 { "settings": { "test": "new value" } }。同样,如果我们想将值设置为显式值,这将继续起作用null:PATCH /chatRooms/1 { "settings": { "test": null } }。但是我们如何着手完全删除密钥呢?换句话说,我们如何将值设置为undefined?
This leads to a tricky situation. Let’s imagine that the settings field is set to { "test": "value" }. To change the value, we can rely on an implicit field mask: PATCH /chatRooms/1 { "settings": { "test": "new value" } }. Likewise, this will continue to work if we want to set the value to an explicit null value: PATCH /chatRooms/1 { "settings": { "test": null } }. But how do we go about removing the key entirely? Put differently, how do we go about setting the value to undefined?
不幸的是,undefined它不是 JSON 规范的一部分,所以像我们在某些 JavaScript 代码中那样显式地使用这个值根本行不通。此外,大多数其他编程语言都没有映射中缺失键的标记值。因此,我们需要另一种机制来从动态数据结构中删除键。
Unfortunately, undefined is not part of the JSON specification, so using this value explicitly like we might in some JavaScript code simply won’t work. Additionally, most other programming languages don’t have a sentinel value for a missing key in a map. As a result, we’ll need another mechanism to remove keys from dynamic data structures.
虽然可以在我们的 API 中使用特殊符号来指示未定义,但这很容易出错,并且可能在以后引入更多的复杂性。它还会与 JSON 值的范围冲突或违反 JSON 规范的规则。简而言之,这个表示未定义的特殊标志不太可能是一个非常可靠的策略。
While it could be possible to use a special symbol to indicate undefined in our API, this is error prone and can introduce more complications later on. It also will either conflict with the range of JSON values or break the rules of the JSON specification. In short, this special flag to represent undefined is unlikely to be a very robust strategy.
另一种选择是依靠完全替换字段。也就是说,我们将检索资源,删除有问题的键,并通过替换整个映射字段来更新资源。虽然这肯定有效(并且我们可以使用新鲜度检查避免数据一致性问题),但它仅在我们更新单个字段时有效。如果整个资源是动态的(就像存储系统的许多 Web API 中的情况一样),这会变得更加复杂,因为我们有效地使用标准替换方法来使其发挥作用。
Another option is to rely on full replacement of the field. That is, we would retrieve the resource, remove the offending key, and update the resource by replacing the entire map field. While this will certainly work (and we can avoid data consistency issues using freshness checks), it only works if we’re updating a single field. If the entire resource is dynamic (as is the case in many web APIs for storage systems), this becomes more complicated as we’re effectively using the standard replace method to make it function.
相反,下一个最佳解决方案是依赖请求正文中缺少值的显式字段掩码。换句话说,我们明确声明我们想要更新特定字段,但我们完全忽略了该字段。例如,清单 8.8 中的请求将确保"test"从资源的设置字段中删除密钥ChatRoom。请注意,这不是将值设置为null,而是实际删除整个值y。
Instead, the next best solution is to rely on an explicit field mask with a missing value in the request body. In other words, we explicitly state that we’d like to update a specific field, but we omit that field entirely. For example, the request in listing 8.8 would ensure that the "test" key is removed from the settings field on a ChatRoom resource. Note that this isn’t setting the value to null, but actually removing the value entirely.
Listing 8.8 Example method to remove a field from a dynamic data structure
PATCH /chatRooms/1?fieldMask=settings.test HTTP/1.1 Content-Type: application/json {} ❶
❶因为重要的是 settings.test 等同于 undefined,所以我们根本不需要提供任何数据!
❶ Since all that matters is that settings.test be equivalent to undefined, we don’t have to provide any data at all!
鉴于此请求,当需要确定要更新的内容时,我们会将输入值分配给资源的值 ( resource["settings"]["test"] = input ["settings"]["test"]),在这种情况下,这将等同于 undefined 并具有从字典中删除键的预期结果。在其他语言中,确定输入是否具有指示的字段很重要,如果没有,则显式删除键。例如,使用 Python 的 是不可接受的input.get('settings', {}).get('test'),因为这会导致值为None. 将值设置为 PythonNone与从 Python 字典中删除键不同。
Given this request, when it comes time to determine what to update, we will assign the input value to the resource’s value (resource["settings"]["test"] = input ["settings"]["test"]), which in this case will be equivalent to undefined and have the desired result of removing the key from the dictionary. In other languages, it will be important to determine if the input had the indicated field present, and if not, explicitly remove the key. For example, it wouldn’t be acceptable to use Python’s input.get('settings', {}).get('test'), as this would result in a value of None. And setting the value to a Python None is not the same as removing the key from the Python dictionary.
这引出了我们的最后一个主题:当您提供一个没有的字段时会发生什么存在?
This leads us to our final topic: what happens when you provide a field that doesn’t exist?
作为我们在 8.3.6 节中了解到,在请求正文中不存在的字段掩码中指定字段是支持从动态数据结构中删除数据的巧妙技巧。但是,如果底层数据结构不是动态的呢?
As we learned in section 8.3.6, specifying fields in a field mask that aren’t present in the body of a request is a neat trick to support removing data from a dynamic data structure. However, what about cases where the underlying data structure isn’t dynamic?
虽然采用防御性编码策略并在提供不可能存在的字段时抛出某种错误可能很诱人,但这不太可能奏效。这样做的主要原因是,正如我们将在第 24 章中看到的那样,添加新字段有时删除它们是很常见的。因此,请求者可能包含一个在一个版本中存在但在当前版本中不再存在的字段,这并不令人难以置信。在这些情况下,更安全的做法是将所有结构简单地视为动态结构,并且在更新时在请求中或检索时在资源中找不到字段掩码中指定的字段时永远不会抛出任何类型的错误。
While it might be tempting to adopt the defensive coding strategy and throw some sort of error when a field is provided that can’t possibly exist, this is unlikely to work very well. The primary reason for this is, as we’ll see in chapter 24, it’s quite common to add new fields and sometimes to remove them. As a result, it’s not unbelievable that a requester might include a field that has existed in one version but no longer exists in the current version. In these cases, it’s safer all around to simply treat all structures as though they’re dynamic and never throw any sort of error when a field specified in a field mask isn’t found in the request when updating or in the resource when retrieving.
换句话说,每当我们有一个未找到的字段时,我们将其值视为undefined更新数据或返回结果。
In other words, whenever we have a field that isn’t found, we treat its value as undefined and either update the data or return the result.
正如我们在 8.3.1 节中了解到的,我们将依赖新FieldMask类型(相当于一个字符串数组)在标准get请求和标准上定义两个新字段更新要求。
As we learned in section 8.3.1, we’ll rely on the new FieldMask type (equivalent to a string array) to define two new fields on the standard get request and standard update request.
Listing 8.9 Final API definition
abstract class ChatRoomApi { @get("/{id=chatRooms/*}") GetChatRoom(req: GetChatRoomRequest): ChatRoom; @patch("/{resource.id=chatRooms/*}") UpdateChatRoom(req: UpdateChatRoomRequest): ChatRoom; } type FieldMask = string[]; interface GetChatRoomRequest { id: string; fieldMask: FieldMask; } interface UpdateChatRoomRequest { resource: ChatRoom; fieldMask: FieldMask; }
部分的使用字段掩码的更新和检索可能非常强大,但重要的是要记住目标仍然非常有限:尽量减少不必要的数据传输并允许对 API 资源进行细粒度修改。通常,将字段掩码和部分检索作为类似 SQL 的查询工具来考虑是很有诱惑力的,以从恰好拥有比其他方式更多数据的资源中获取特定数据。虽然可以使用野外掩模来实现这一目标,但它们当然不应该。
Partial updates and retrievals using field masks can be very powerful, but it’s important to remember that the goal is still quite limited: to minimize unnecessary data transfer and allow fine-grained modification of API resources. Often, it can be tempting to consider field masks and partial retrievals in particular as SQL-like querying tools to fetch specific data from a resource that happens to have far more data than it otherwise should. While field masks may be used to accomplish this goal, they certainly should not.
在 API 有大量相关数据并且需要一种机制让用户根据不同资源之间的关系检索特定数据的情况下,字段掩码对于这项工作来说是一个功能不足的工具。相反,像 GraphQL 这样的东西更合适,它提供了一个强大的系统来连接 API 中的相关数据,同时还提供了使用类似于字段掩码的查询格式来限制结果数据的能力。虽然我们不会在本书中深入探讨 GraphQL,但如果您发现自己需要这种类型的功能,那么它当然值得一看。
In the cases where an API has lots of related data and needs a mechanism for users to retrieve specific data based on relationships between the different resources, field masks are an underpowered tool for the job. Instead, something like GraphQL is a far better fit, providing a powerful system for joining related data in an API, while also providing the ability to limit the resulting data using query formats that resemble field masks. And while we won’t get into GraphQL in this book, if you find yourself needing this type of functionality, it’s certainly worth looking at.
现在我们已经了解了部分检索和更新如何使用字段掩码进行工作,让我们看看其他一些值得探讨的注意事项。
Let’s look at a few other considerations worth exploring now that we’ve seen how partial retrievals and updates work using field masks.
尽管支持标准更新方法的部分更新是一个硬性要求(否则该方法实际上只是一个标准的替换方法),这根本不是每个 API 都必须支持部分检索的要求。原因很简单:并非每个 API 都拥有大到或复杂到值得支持部分检索的资源。相反,即使是小资源也可以从允许对资源上更新的字段进行细粒度控制中受益,因为这更多是并发性问题,而不是资源大小和复杂性。
While it is a hard requirement to support partial updates for a standard update method (otherwise the method would really just be a standard replace method), it is not at all a requirement that every API must support partial retrieval. The reason for this is pretty simple: not every API has resources that are so large or complex that they merit supporting partial retrievals. Contrarily, even a small resource can benefit from allowing fine-grained control over what fields are updated on a resource, as that’s more an issue of concurrency than resource size and complexity.
然而,重要的是要注意,如果一个 API 决定支持部分检索,它应该全面支持而不是逐个资源地支持。换句话说,如果一个 API 以单个资源结束,该资源最终需要支持部分检索,则该 API 应该跨所有资源实现该功能。这样做的原因是为了一致性。标准 get 方法的目标是跨资源保持一致,因此引入任何取决于您正在与之交互的资源的可变性会导致意外,最终导致更糟糕的 API消费者。
It is important to note, however, that if an API does decide to support partial retrieval, it should do so across the board rather than on a resource-by-resource basis. In other words, if an API ends up with a single resource that ends up requiring support for partial retrievals, the API should implement the functionality across all resources. The reason for this is aimed at consistency. The goal of a standard get method is to be consistent across resources, so introducing any variability that depends on which resource you’re interacting with leads to surprises, resulting ultimately in a worse-off API for consumers.
它是还需要注意的是,还有一些其他实现可用于支持使用 HTTPPATCH方法对资源进行部分更新. 例如,JSON Patch (RFC-6902; https://tools.ietf.org/html/rfc6902 ) 在您需要对正在修改的资源进行更明确和详细的控制的情况下是一个极好的选择。JSON Patch 依赖于一系列操作(有点像操作转换),这些操作被顺序应用以修改 JSON 文档。JSON Patch 不仅决定设置哪些值,还提供高级功能,例如将值从一个字段复制到另一个字段(无需首先检索该值)或使用索引和test操作操作数组值的能力断言该索引处的项目确实是预期的项目。
It’s also important to note that there are several other implementations available for supporting partial updates of resources with the HTTP PATCH method. For example, JSON Patch (RFC-6902; https://tools.ietf.org/html/rfc6902) is a fantastic option in the case where you need far more explicit and detailed control over the resource that’s being modified. JSON Patch relies on a series of operations (sort of like operational transforms) that are applied sequentially to modify a JSON document. Rather than just deciding which values to set, JSON Patch provides advanced functionality such as the ability to copy values from one field to another (without first retrieving that value) or manipulating array values using indexes and the test operation to assert that the item at that index is indeed the intended item.
JSON Patch 并不是唯一的选择。还有 JSON Merge Patch(RFC-7396;https: //tools.ietf.org/html/rfc7396 ),它更接近本章探讨的字段掩码实现,但它有自己的怪癖列表,并且是,本身完全基于推断或隐式字段掩码。null正如您可能猜到的那样,当需要区分显式设置值和完全从结构中删除它们时,这会带来动态数据结构的问题。此外,它对嵌套的 JSON 对象(例如,映射和嵌套接口)是递归的,但对重复的字段(例如,数组)不是递归的,这会导致对这些更丰富的数据结构的潜在混淆。
And JSON Patch is not the only other option. There’s also JSON Merge Patch (RFC-7396; https://tools.ietf.org/html/rfc7396), which is a bit closer to the field mask implementation explored in this chapter, but it has its own list of quirks and is, itself, entirely based on an inferred or implicit field mask. As you might guess, this brings about issues with dynamic data structures when it comes time to distinguish between explicitly setting values to null and removing them from the structure entirely. Further, it’s recursive for nested JSON objects (e.g., maps and nested interfaces) but not for repeated fields (e.g., arrays), leading to potential confusion for these richer data structures.
虽然其中许多都是不错的选择,但使用字段掩码指定应检索或更新的确切字段的想法足够简单,大多数人可以快速掌握,同时仍然足够强大,可以提供必要的功能以在检索或更新时保持精确的准确性。更新资源。
While many of these are great options, the idea of using field masks to specify the exact fields that should be retrieved or updated is simple enough for most to grasp quickly while still powerful enough to provide the necessary functionality to maintain pinpoint accuracy when retrieving or updating resources.
想象资源可能有ChatRoom管理员用户的集合,而不是单个管理员。使用数组字段来存储这些资源,我们会失去什么?有哪些替代方案?
Imagine a ChatRoom resource might have a collection of administrator users rather than a single administrator. What do we lose out on by using an array field to store these resources? What alternatives are available?
How do we go about removing a single attribute from a dynamic data structure?
How do we communicate which fields we’re interested in with a partial retrieval?
How do we indicate that we’d like to retrieve (or update) all fields without listing all of them out in the field mask?
What field mask would we use to update a key "`hello.world`" in a map called settings?
Partial retrieval is particularly important in cases where resources are large or clients consuming resource data have limited hardware.
Partial updates are critical for fine-grained updates without worrying about conflicts.
Field masks, which support ways to address fields, nested fields in interfaces, and map keys, should be used to indicate the fields that should be retrieved or updated.
Field masks should not provide a mechanism to address items in array fields by their position or index in that field.
默认情况下,字段掩码应该为部分检索假定所有内容的值,并为部分更新假定一个隐式字段掩码(其中指定的字段是根据它们的存在来推断的)。
By default, field masks should assume a value of everything for partial retrievals and an implicit field mask (where the fields specified are inferred based on their presence) for partial updates.
If fields are invalid, they should be treated as though they do exist but have a value of undefined.
通常,我们需要对 API 资源执行一些操作,这些操作不适合标准方法之一。虽然这些行为在技术上可以由资源的标准更新方法处理,但其中许多操作的行为要求对于标准方法来说是完全不合适的,从而导致令人惊讶、混乱和过于复杂的界面。为了解决这个问题,本模式探讨了如何在 Web API 中安全地支持对资源的这些操作,同时使用我们称之为自定义方法的方法维护一个简单、可预测且功能强大的 API 。
Often, there will be actions we need to perform on our API resources that won’t fit nicely into one of the standard methods. And while these behaviors could technically be handled by the resource’s standard update method, the behavioral requirements of many of these operations would be quite out of place for a standard method, leading to a surprising, confusing, and overly complex interface. To address this, this pattern explores how to safely support these actions on resources in a web API while maintaining a simple, predictable, and functional API using what we’ll call custom methods.
在大多数 API 中,有时我们需要能够表达不太适合标准方法之一的特定操作。例如,发送电子邮件或即时翻译某些文本的正确 API 是什么?如果您被禁止存储任何数据怎么办(也许您正在为中央情报局翻译文本)?虽然您可以将这些操作混搭成一个标准方法(很可能是创建操作或更新操作),但此时您有点扭曲了这些标准方法的框架以适应不太合适的东西。这就引出了一个明显的问题:我们是否应该尝试将我们想要的行为塞进现有方法中,稍微弯曲框架以适应我们的需要?或者我们应该将我们的行为改变为更适合框架的形式吗?或者我们应该改变框架以适应我们的新场景?
In most APIs, there will come a time when we need the ability to express a specific action that doesn’t really fit very well in one of the standard methods. For example, what’s the right API for sending an email or translating some text on the fly? What if you’re prohibited from storing any data (perhaps you’re translating text for the CIA)? While you can mash these actions into a standard method (most likely either a create action or an update action), at that point you’re sort of bending the framework of these standard methods to accommodate something that doesn’t quite fit. This leads to an obvious question: should we try to jam the behavior we want into the existing methods, bending the framework a bit to fit our needs? Or should we change our behavior into a form that fits a bit more cleanly within the framework? Or should we change the framework to accommodate our new scenario?
这个问题的答案取决于具体情况,但在本章中,我们将探索自定义方法作为这个难题的一种潜在解决方案。从本质上讲,这与第三种选择一致,我们改变框架以适应我们的新场景,从而确保没有任何事情只是为了让事情适合。
The answer to this depends on the scenario, but in this chapter we’ll explore custom methods as one potential solution to this conundrum. Essentially, this goes with the third option where we change the framework to fit our new scenario, thereby ensuring that nothing is bent just to make things fit.
您可能会猜到,使用自定义方法背后的概念并不复杂——毕竟,大多数基于 RPC 的 API 一直都在使用这个想法。自定义方法的棘手部分在于细节。什么时候使用它们是安全的?我们真的确定标准方法不是一种选择吗?我们可以参数化自定义方法调用吗?清单还在继续。
As you might guess, the concepts behind using custom methods are not complicated—after all, most RPC-based APIs use this idea all the time. The tricky part with custom methods lies in the details. When is it safe to use them? Are we really sure a standard method isn’t an option? Can we parameterize a custom method call? The list goes on.
在本章中,我们将探讨自定义方法及其带来的所有细节和细微差别。但在我们了解自定义方法如何工作之前,让我们花点时间解决房间里的大象问题:我们真的需要使用自定义方法吗?
In this chapter, we’ll explore custom methods and all of the details and nuance that they bring with them. But before we get into how custom methods work, let’s take a moment to address the elephant in the room: do we really need to use custom methods?
尽管我们在第 7 章中学到的标准方法通常足以用 API 做几乎所有事情,但有时他们会觉得不太合适。在这些情况下,我们常常感觉好像是在遵守法律的条文,而不是法律的精神:标准方法的行为与预期完全不同,导致意外,因此不是一个很好的 API。简而言之,仅仅因为我们可以用标准方法执行某项操作并不意味着我们应该执行该操作。但这又是由什么决定的呢?除了感觉不对之外,我们还能做更多的解释吗?让我们通过一个具体的例子来看这个:状态变化。
While the standard methods we learned about in chapter 7 are often sufficient to do pretty much anything with an API, there are times where they feel not quite right. In these cases, it often feels as though we’re following the letter of the law, but not the spirit of that law: having standard methods act sufficiently different from expectations, leading to surprise, and therefore not a very good API. In short, just because we can perform an action with a standard method doesn’t mean that we should perform that action. But what determines this? Can we put anything more explanatory besides it feels wrong? Let’s look at this by way of a concrete example: state changes.
通常,API 资源可以以多种状态之一存在。例如,一封电子邮件可能以草稿状态开始,然后进入已发送状态,并且可能(如果我们使用一些高级电子邮件服务)未发送。这显然是我们可以使用标准更新方法的场景之一,但这样做似乎不太正确。
Often, API resources can exist in one of several states. For example, an email might start out in a draft state, then move into a sent state, and possibly (if we’re using some fancy email service) unsent. This is obviously one of those scenarios where we could use our standard update method, but it just doesn’t seem quite right to do so.
为什么状态变化不适合更新方法?第一个原因很简单:大多数状态更改都是某种类型的转换,因此,转换到新状态(恰好存储在某个状态字段中)与设置标量字段的值根本不同(例如,设置电子邮件的主题)。因此,设置状态以指示此转换的发生可能既令人困惑又令人惊讶,这两者都不是一个好的 API。
Why exactly are state changes not a great fit for an update method? The first reason is pretty simple: most state changes are transitions of some sort or another, and as a result, transitioning to a new state (which happens to be stored in some state field) is fundamentally different from setting the value of a scalar field (e.g., setting the subject of the email). Because of this, setting the state to indicate this transition happening can be both confusing and surprising, neither of which makes for a good API.
Listing 9.1 Marking an email as sent using the standard update method
const email = GetEmail({id: 'email id here'}); ❶ UpdateEmail({id: email.id, state: 'sent'}); ❷
const email = GetEmail({id: 'email id here'}); ❶ UpdateEmail({id: email.id, state: 'sent'}); ❷
❶ To start, an email’s state is draft.
❷在此示例中,我们通过将其状态字段更新为已发送来发送电子邮件。
❷ In this example, we send the email by updating its state field to sent.
如您所见,通过更新状态属性来发送电子邮件有点令人反感。这个字段感觉应该由 API 服务自己管理,而不是由客户端更新。简而言之,将字段视为我们从一种状态转换到另一种状态的机制可能会非常令人困惑。此外,它混淆了更新方法的真正目的,因为它提供了同时更新存储数据和转换到新状态的能力——两个独立的概念操作。
As you can see, causing an email to be sent by updating a state property is a bit off-putting. This field feels like it should be managed by the API service itself rather than updated by the client. In short, treating a field as a mechanism by which we transition from one state to another can be pretty confusing. Further, it conflates the true purpose of the update method as it provides the ability to both update stored data as well as transition to a new state at the same time—two separate conceptual actions.
除了更新字段以转换到新状态所造成的混乱之外,我们还有第二个与副作用有关的潜在问题。正如我们在第 7 章中了解到的,标准方法除了其名称所暗示的特定操作外,实际上不应该做任何事情。换句话说,create 方法应该创建一个新资源并且什么都不做。同样,更新方法应该更新资源,仅此而已。但通常情况下,状态更改伴随着需要执行的其他操作。例如,如果我们有一个电子邮件消息资源,我们想要将其从草稿更改为已发送,我们还必须实际将电子邮件发送给收件人(否则,将其标记为已发送实际上是没有意义的)。
In addition to the confusion caused by updating a field to transition to a new state, we have a second potential issue having to do with side effects. As we learned in chapter 7, standard methods really should not do anything besides the specific action their name implies. In other words, a create method should create a new resource and do nothing else. Similarly, an update method should update a resource and nothing more. But often, state changes come with additional actions that need to be performed. For example, if we have an email message resource that we want to change from draft to sent, we also have to actually send the email to the recipients (otherwise, marking it as sent is effectively meaningless). And simply updating a state field to sent is a pretty subtle way to imply that you’re going to talk to an SMTP server in order to send an email to some recipient.
问题只会从那里扩大。当我们使用标准更新方法执行具有此类副作用的操作时,我们必须考虑这样一个事实,即现在可能需要与两个不同的系统进行通信才能完成更新操作(如图 9.1 所示)。首先,对于每次更新,无论如何,我们都需要与存储系统通信,以使用新信息更新某处的数据库。其次,如果更新恰好将电子邮件资源的状态从草稿更改为已发送,我们还需要与电子邮件发送系统通信(它必须进一步与收件人的电子邮件服务通信)以完成工作。原本应该是一个简单的“更新数据库”操作,现在变成了一个杂乱的依赖链和潜在的长时间运行的后台操作,其中任何一步都可能出现故障。
The problem only expands from there. When we use a standard update method to perform actions with side effects such as these, we have to consider the fact that there are now potentially two different systems we need to communicate with in order to complete the update operation (shown in figure 9.1). First, for every update, no matter what, we need to communicate with a storage system to update a database somewhere with the new information. Second, if the update happens to change an email resource’s state from draft to sent, we also need to communicate with an email sending system (which must further communicate with a recipient’s email service) to get the job done. What should be a simple “update the database” operation is now a messy chain of dependencies and potentially long-running background operations where failures could arise at any step.
图 9.1 API 可能需要与多个不同的系统进行通信以进行单个更新。
Figure 9.1 The API may need to communicate with multiple different systems for a single update.
更糟糕的是,这意味着,根据正在更新的内容,标准更新方法可能会立即返回结果或延迟一段时间直到电子邮件发送完成。当用户只是想更新有关其电子邮件的某些内容时,这会导致各种混乱。
To make things even worse, this means that, depending on the content being updated, the standard update method might either return a result immediately or delay awhile until the email sending has taken place. This leads to all sorts of confusion when a user is simply trying to update some content about their email.
请务必注意,此图在技术上没有任何错误。许多 API 调用可能会与许多不同的服务进行通信,这不一定是坏事。问题是这张图打破了之前定义的关于标准方法应该做什么和不应该做什么的规则。因此,问题不在于与多个不同系统对话的 API 调用;它是关于一种标准方法(例如更新)与多个不同系统对话、触发副作用并承担下游依赖性。这使得 API 从根本上变得更糟,因为它削弱了我们依赖标准方法某些方面的能力,反而使标准方法变得不可预测和令人困惑。
It’s important to note that there is nothing technically wrong with this diagram. Lots of API calls may communicate with lots of different services, and that’s not necessarily a bad thing. The problem is that this diagram breaks the rules defined earlier about what standard methods should and should not do. As a result, the problem isn’t about an API call that talks to multiple different systems; it’s about a standard method (such as an update) talking to multiple different systems, triggering side effects, and taking on downstream dependencies. This makes an API fundamentally worse by blowing away our ability to rely on certain aspects of standard methods and instead making a standard method unpredictable and confusing.
在我们总结之前,我们还有一头大象要在房间里解决:为什么不重新安排我们的资源,以便我们想要执行的操作更符合标准方法的期望?在这个例子中,我们可能有EmailDraft资源和Email资源,我们不在状态之间转换,而是Email基于资源创建新EmailDraft资源。
Before we wrap this up, we have one more elephant to address in the room: why not rearrange our resources so that the actions we want to perform fit more closely with the expectations of standard methods? In this example, we might instead have EmailDraft resources and Email resources, where we don’t transition between states but instead create a new Email resource based off an EmailDraft resource.
Listing 9.2 Arranging resources to fit with the standard methods
abstract class EmailApi { @post("/{parent=users/*}/emailDrafts") ❶ CreateEmailDraft(CreateEmailDraftRequest req): EmailDraft; @post("/{parent=users/*}/emails") ❷ CreateEmail(CreateEmailRequest req): Email; } interface EmailDraft { ❸ id: string; subject: string; // ... } interface Email { id: string; content: EmailDraft; ❹ }
❶ We always start by creating mutable EmailDraft resources.
❷创建电子邮件资源实际上也会发送电子邮件。电子邮件资源将是不可变的。
❷ Creating an Email resource would actually send the email as well. Email resources would be immutable.
❸ An EmailDraft resource contains all the key information that would be sent.
❹ The actual Email references the EmailDraft content.
虽然这当然是可以接受的,但像这样的洗牌假设仅依赖标准方法是 API 设计的一些神圣不可侵犯的原则。事实上,标准方法是达到目的的手段。在这种情况下,目标是一个可操作的、富有表现力的、简单的和可预测的 API。当设计偏离这一点时,调整工具以适应 API 的需求几乎肯定比相反更好。
While this is certainly acceptable, shuffles like these assume that relying only on standard methods is some sacrosanct principle of API design. In truth, standard methods are a means to an end; in this case, the goal is an operational, expressive, simple, and predictable API. When a design takes away from that, it is almost certainly better to adjust your tools to fit the needs of the API rather than the other way around.
我们该怎么办?显而易见的答案是依赖一个单独的 API 调用,它不是标准方法(因此可能有副作用),将功能隔离到一个地方,而不是重载现有的标准方法。虽然这看起来很简单,但与 API 设计中的大多数事情一样,细节决定成败。现在我们已经了解了为什么需要自定义方法,在下一节中我们将简要介绍什么是自定义方法以及一些常见用途。
What do we do instead? The obvious answer is to rely on a separate API call that is not a standard method (and therefore can have side effects) to isolate the functionality into a single place rather than overloading the existing standard methods. While this might seem pretty simple, as with most things in API design, the devil is in the details. Now that we’ve gone through why custom methods are necessary, in the next section we’ll briefly go through what custom methods are and some common uses.
风俗方法只不过是超出标准方法范围的 API 调用,因此不受我们对标准方法施加的严格要求的约束。它们可能看起来有点不同,正如我们将在下一节中看到的那样,但是从纯技术的角度来看,自定义方法确实没有什么特别之处。
Custom methods are nothing more than API calls that fall outside the scope of a standard method and therefore aren’t subject to the strict requirements that we impose on standard methods. They might look a bit different, as we’ll see in the next section, but from a purely technical standpoint there is really nothing special about custom methods.
标准方法为我们提供了一些与我们的 API 一起使用的奇妙构建块,但是这个非常广泛的范围的成本是大量收集标准方法的指南、规则和限制。另一方面,自定义方法几乎没有任何限制,这意味着它们可以自由地做最适合场景的事情,而不是强迫场景适应标准方法的结构和规则。
Standard methods offer us some fantastic building blocks to use with our APIs, but the cost for this very broad scope is the extensive collection of guidelines, rules, and restrictions of standard methods. Custom methods, on the other hand, have almost no restrictions whatsoever, meaning they are free to do whatever is best for the scenario rather than forcing the scenario to fit the structure and rules of a standard method.
这也带来了一些缺点,特别是 API 的用户不能像对标准方法列表那样对自定义方法做出尽可能多的假设。这并不是说 API 中的自定义方法都应该相互矛盾或不一致。相反,自定义方法应该在整个 API 中保持一致。然而,要点是对于开箱即用的自定义方法通常没有硬性规定。相反,选择权留给 API 设计者来决定一组规则和先例,然后这些规则应该在整个 API 中保持一致。
This also comes with some downsides, particularly the fact that users of an API can’t make as many assumptions about a custom method as they might with the list of standard methods. This is not to say that custom methods in an API should all be contradictory or inconsistent. On the contrary, custom methods should be consistent across an API. However, the point is that there are generally no hard and fast rules for custom methods out of the box. Instead, the choice is left to the API designer to decide on a set of rules and precedents, and then those rules should be consistent across that API.
虽然这个模式的概念很简单(参见清单 9.3 的自定义方法示例),但这个模式很快就会变得复杂,因为我们必须处理很多不同的场景以及它们背后的细微差别。例如,如果状态变化需要额外的上下文或参数化,那么应该如何提供额外的信息?如果该方法对一组资源而不是单个资源进行操作怎么办?如果根本不涉及任何状态呢?
While the concept of this pattern is simple (see listing 9.3 for an example custom method), the pattern becomes complicated quickly as we have to work through quite a few different scenarios and the nuance behind them. For example, if state changes require extra context or parameterization, how should that extra information be provided? What if the method operates on a collection of resources rather than a single resource? What about the case where there’s no state involved at all?
Listing 9.3 Example custom method launching a rocket
abstract class RocketApi { @post("/{id=rockets/*}:launch") ❶ LaunchRocket(LaunchRocketRequest req): Rocket; ❷ } interface Rocket { id: string; // ... } interface LaunchRocketRequest { id: string; }
❶自定义方法使用 POST HTTP 动词和特殊的“ :”分隔符来声明操作本身。
❶ Custom methods use the POST HTTP verb and a special ":" separator to declare the action itself.
❷自定义方法遵循与标准方法类似的命名约定 (<Verb><Noun>)。
❷ Custom methods follow similar naming conventions to standard methods (<Verb><Noun>).
既然我们已经看到了自定义方法的样子,并全面了解了为什么我们首先需要这些方法,那么让我们深入了解自定义方法的细节工作。
Now that we’ve seen what a custom method looks like and gone through the bigger picture of why we need these in the first place, let’s get into those details of how custom methods work.
在在大多数情况下,自定义方法看起来就像标准方法。存在一些差异,其中之一是 HTTP 请求的格式。虽然标准方法依赖于请求路径和 HTTP 方法的组合来指示方法的行为(例如,PATCH在资源上总是表示更新标准方法),自定义方法不能依赖相同的机制;HTTP 动词非常有限。相反,自定义方法有自己的特殊格式,将相关信息放在请求的路径中。让我们根据图 9.2 中所示的示例,逐个分析这种格式。
In most ways, custom methods look just like standard methods. There are a few differences, one being the format of the HTTP request. While standard methods rely on a combination of the request path and the HTTP method to indicate the behavior of the method (e.g., PATCH on a resource always indicates an update standard method), custom methods can’t rely on that same mechanism; the HTTP verbs are quite limited. Instead, custom methods have their own special format that puts the relevant information in the path of the request. Let’s go through this format piece by piece, relying on the example shown in figure 9.2.
Figure 9.2 HTTP request components for a custom rocket launching method
首先,自定义方法的HTTP方法几乎都是POST. 让自定义方法使用 HTTP 方法可能是有意义的GET,但更有可能是单独的子资源(参见第 12 章)更适合。DELETE依赖一种方法也可能有意义,但这些例子相对较少。
First, the HTTP method for custom methods is almost always POST. It might make sense to have a custom method use the GET HTTP method, but it’s far more likely that a singleton sub-resource (see chapter 12) will be a better fit. It also might make sense to rely on a DELETE method, but those examples are relatively rare.
虽然资源路径与标准方法相同,但路径末尾的操作有一个区别。为避免混淆资源(或集合)的停止位置和自定义操作的开始位置,至关重要的是我们不要重复使用正斜杠字符作为这两个关键组件(例如,POST /rockets/1234567/launch)之间的分隔符。相反,我们可以使用冒号字符(“ :”)来指示资源已结束并且自定义操作已开始。这可能看起来有点奇怪,但避免任何歧义很重要,尤其是因为如果我们使用正斜杠,路径在技术上是有效的。
While the resource path is identical to standard methods, there is one difference with the action at the very end of the path. To avoid any confusion about where the resource (or collection) stops and the custom action begins, it’s critical that we don’t reuse the forward slash character as a separator between these two key components (e.g., POST /rockets/1234567/launch). Instead, we can use the colon character (“:”) to indicate that the resource has ended and a custom action has begun. This might look a bit strange, but it’s important to avoid any ambiguity, especially because the path is technically valid if we were to use a forward slash.
最后,如清单 9.3 所示,RPC 名称应遵循动词后跟名词的相同命名约定,例如LaunchRocketor ArchiveDocument,就像任何其他标准方法一样。这一点特别重要,尤其是避免使用诸如“with”或“for”之类的介词的指南(例如,避免使用诸如 之类的自定义方法CreateRocketForMars),因为这些自定义方法的格式应与标准方法一样好。换句话说,自定义方法不是标准方法参数化的机制。
Finally, as shown in listing 9.3, the RPC name should follow the same naming convention of a verb followed by a noun, such as LaunchRocket or ArchiveDocument, just like any other standard methods. This is particularly important, especially the guidelines of avoiding using prepositions such as “with” or “for” (e.g., avoid custom methods like CreateRocketForMars), as these custom methods should be just as well formed as standard methods. In other words, custom methods are not a mechanism for parameterization of standard methods.
也许自定义方法和标准方法之间最大的区别是对副作用的接受程度。正如我们在第 7 章中了解到的,标准方法的目标是成为访问和操作资源数据的有限机制。标准方法的关键原则之一是它完全按照它所说的去做,而不会在后台触发其他额外的操作,这些操作不是该方法真正打算做的事情的核心。换句话说,如果一个方法说它创建了一个资源,它就应该创建那个资源,但它也不能做任何其他事情。
Perhaps the largest difference between custom methods and standard methods is the acceptance of side effects. As we learned in chapter 7, the goal of standard methods is to be a limited mechanism to access and manipulate resource data. And one of the key principles of a standard method is that it does exactly what it says it does, without triggering other extra actions in the background that aren’t core to what exactly the method is intended to do. In other words, if a method says it creates a resource, it should create that resource, but it also must not do anything else.
自定义方法没有此类限制。相反:自定义方法正是产生副作用的正确位置。原因很简单:我们对他们能做什么和不能做什么有一系列期望和标准方法。自定义方法,就其本质而言,不受这些限制——可以这么说。因此,诸如发送电子邮件、触发后台操作、更新多个资源以及您能想到的任何其他副作用,都在菜单上。
Custom methods have no such limitations. On the contrary: custom methods are exactly the right place to have actions with side effects. The reason for this is pretty simple: we have a set of expectations with standard methods about what they can and cannot do. Custom methods, by their very nature, are free of those limitations—all bets are off, so to speak. So side effects, such as sending emails, triggering background operations, updating multiple resources, and anything else you can imagine, are all on the menu.
例如,电子邮件 API 显然需要一种发送电子邮件的机制;但是,依赖于CreateEmail将电子邮件存储在系统中并通过 SMTP 发送电子邮件是不可接受的答案。相反,CreateEmail可能用于创建处于草稿状态的电子邮件,然后是自定义SendEmail方法可用于通过 SMTP 与远程服务器通信,并且仅在成功发送电子邮件后,才将电子邮件转换为已发送状态。使用如图 9.3 所示的流程,意味着标准方法(在这种情况下,CreateEmail方法) 保持纯粹和简单,只有一项工作:将记录保存在数据库中的某个地方。然后为了做更奇特、更复杂的事情(比如与远程 SMTP 服务器通信),我们依赖于自定义方法,它可以自由执行实现方法目标所需的任何操作。
For example, an email API will obviously need a mechanism for sending an email; however, relying on CreateEmail to both store the email in the system and send the email over SMTP is not an acceptable answer. Instead, CreateEmail might be used to create an email in a draft state, and then a custom SendEmail method could be used to do the work of talking to remote servers over SMTP and, only after the email has been sent successfully, transitioning the email into a sent state. Using this flow, shown in figure 9.3, means that the standard methods (in this case, the CreateEmail method) remains pure and simple, with one and only one job: save a record in a database somewhere. Then to do fancier, more complicated things (such as talking to remote SMTP servers), we rely on a custom method, which is free to perform whatever actions necessary to accomplish the goal of the method.
Figure 9.3 Sequence diagram demonstrating sending an email using custom methods
到目前为止,我们已经暗示自定义方法可以像标准方法一样应用于资源和集合,但这值得进一步探索。SendEmail让我们看看什么时候使用以资源为目标的自定义方法(例如示例)与以集合为目标的自定义方法有意义方法。
So far, we’ve hinted that custom methods can apply to both resources and collections just like standard methods, but this merits further exploration. Let’s look at when it makes sense to use a resource-targeted custom method (e.g., the SendEmail example) versus a collection-targeted custom method.
在在我们的标准方法列表中,一些对单个资源进行操作(例如,更新资源),而另一些对父集合进行操作(例如,列出资源),总结在表 9.1 中。但是,由于自定义方法是根据具体情况进行自定义的,因此在确定自定义方法是应该对单个资源还是对父集合进行操作时,这可能会让人感到困惑。
In our list of standard methods, some operate on a single resource (e.g., updating a resource) and others operate on a parent collection (e.g., listing resources), summarized in table 9.1. However, since custom methods are by definition customized to the circumstances, this may present a bit of a quandary when it comes to determining whether a custom method should operate on a single resource or a parent collection.
Table 9.1 Different aspects when importing and exporting data
例如,假设我们需要导出属于单个用户的所有电子邮件(有关此模式的更多信息,请参阅第 23 章)或对一组项目执行单个标准操作(例如,使用单个 API 删除一堆电子邮件称呼)。这些操作应该以资源本身(例如,POST /users/1:exportEmails)还是资源集合(例如,POST /users/1/emails:export)为目标。虽然从技术上讲,这两者之间没有区别,但当涉及同一集合中的多个资源时,对集合进行操作几乎总是更好的选择,而将以资源为目标的自定义方法用于仅涉及单个资源的操作。
For example, imagine we need to export all of the emails belonging to a single user (see chapter 23 for more on this pattern) or perform a single standard operation on a set of items (e.g., delete a bunch of emails with a single API call). Should these operations target the resource itself (e.g., POST /users/1:exportEmails) or the collection of resources (e.g., POST /users/1/emails:export). While technically there is no difference between these two, it’s almost always a better choice to operate on a collection when there are multiple resources from that same collection involved, leaving the resource-targeted custom methods for operations involving just that single resource.
导出所有用户信息(包括电子邮件集)之类的东西怎么样?在这种情况下,自定义方法的焦点已经转移回父资源并远离电子邮件集合(它们仍然涉及,只是不是主要焦点)。因此,整个用户信息的这种自定义导出方法将更好地表示为POST /users/1:export.
What about something like exporting all user information, including the set of emails? In this case, the focus of the custom method has shifted back to the parent resource and away from the collection of emails (they’re still involved, just not the primary focus). As a result, this custom export method of an entire user’s information would be better represented as POST /users/1:export.
最后,如果您在跨多个不同父级的一组资源上运行,情况会怎样?例如,也许我们需要归档属于一组不同用户(例如,users/1/emails/2和users/2/ emails/4)的一组电子邮件。在这种情况下,将应用相同的格式,但需要注意的是父标识符将保留为通配符。换句话说,这个作为 HTTP 请求的操作看起来像POST /users/-/emails:archive,依靠连字符指示通配符和请求正文指示哪些电子邮件 ID存档。
Finally, what about a scenario where you’re operating on a set of resources across multiple different parents? For example, perhaps we need to archive a set of emails that belong to a bunch of different users (e.g., users/1/emails/2 and users/2/ emails/4). In this scenario, the same format would apply, with the caveat being that the parent identifier would be left as a wildcard. In other words, this operation as an HTTP request would look something like POST /users/-/emails:archive, relying on the hyphen character to indicate a wildcard and the body of the request to indicate which email IDs should be archived.
所以到目前为止,示例中的所有自定义方法都已附加到特定目标,可以是资源本身,也可以是集合和父资源。然而,由于自定义方法在技术上可以做任何他们想做的事情,所以有一种可能性我们还没有考虑:如果自定义方法没有任何状态要处理并且不需要附加到资源或一个集合?
So far, all of the custom methods in the examples have been attached to a specific target, either a resource itself or a collection and the parent resource. However, since custom methods technically can do whatever they want, there’s a possibility that we haven’t had to consider yet: what if the custom method doesn’t have any state to handle and doesn’t need to be attached to a resource or a collection?
这种称为无状态方法的方法相对常见,随着越来越多的数据存储限制出现,它甚至可以成为一项关键功能。例如,不同的数据隐私法规,例如通用数据保护法规(GDPR),强加了一些关于数据必须存放在哪里以及如何存储的非常具体的规则。像这样的要求意味着拥有一个无状态的方法来动态处理数据并返回结果——特别是不存储所提供的任何数据——是一个可以添加到集合中的有价值的工具。自定义方法是处理此类需求的理想方式。
This type of method, called a stateless method, is relatively common and can even become a critical piece of functionality as more and more restrictions on data storage enter the picture. For example, the different data privacy regulations, such as the General Data Protection Regulation (GDPR), impose some pretty specific rules about where data must live and how it must be stored. Requirements like these mean that having a stateless method that processes data on the fly and returns a result—notably without storing any of the data that was provided—is a valuable tool to add to the collection. And custom methods are an ideal way of handling requirements like these.
Listing 9.4 Stateless custom method to translate text
abstract class TranslationApi { @post("/text:translate") ❶ TranslateText(req: TranslateTextRequest): TranslateTextResponse; } interface TranslateTextRequest { ❷ sourceLanguageCode: string; targetLanguageCode: string; text: string; } interface TranslateTextResponse { ❸ text: string; }
❶在这种情况下,“text”有点像一个单例子资源,它附加了一个名为“translate”的无状态自定义方法。
❶ In this case, “text” is sort of like a singleton sub-resource that has a stateless custom method called “translate” attached.
❷ The request to translate text has no stored data whatsoever.
❸ Similarly, the result has just the translated text and stores nothing.
纯粹的无状态方法比较少见。毕竟,许多 API 至少需要知道用于对 API 请求收费的计费详细信息。因此,将一些充当权限或计费容器(例如,项目、计费帐户或组织)的父资源作为自定义方法的目标资源,将其他无状态方法附加到包含资源。例如,翻译文本可能不是免费赠送的东西,因此 API 可能需要用户创建一个项目资源来跟踪所有正在进行的翻译的计费详细信息。
Purely stateless methods are relatively rare. After all, many APIs will need to at least know the billing details to use to charge for the API request. As a result, it’s quite common to have some parent resource that acts as a permission or billing container (e.g., a project, billing account, or organization) as the resource that’s targeted with the custom method, attaching the otherwise stateless method to that containing resource. For example, translating text might not be something to give away for free, so an API might require a user to create a project resource that keeps track of billing details for all this translation going on.
Listing 9.5 Stateful custom method with a parent as an anchor
abstract class TranslationApi { @post("/{parent=projects/*}/text:translate") ❶ TranslateText(req: TranslateTextRequest): TranslateTextResponse; } interface TranslateTextRequest { parent: string; ❶ sourceLanguageCode: string; targetLanguageCode: string; text: string; } interface TranslateTextResponse { text: string; }
❶ In this case, we attach the custom method to a parent project resource.
最重要的是,虽然无状态自定义方法在当前看来可能是最好的想法,但重要的是要预测未来以及您可能期望引入的任何未来功能。例如,现在可能只有一种方式来翻译某些文本。但随着更多的机器学习技术问世,或许未来会出现多种不同的机器学习模型,能够进行不同形式的翻译。这在医学等特定行业尤为常见,与在随意对话中翻译文本相比,翻译文本使用了很多不同的术语和句法。此外,API 用户可能希望部署他们自己的自定义机器学习模型或自定义词汇表以进行翻译。
Most importantly, while stateless custom methods might seem like the best idea for the current moment, it’s important to anticipate the future and any future functionality you might expect to introduce. For example, right now there might be one and only one way to translate some text. But as more machine learning technology comes out, perhaps in the future there will be a variety of different machine learning models capable of different forms of translation. This is particularly common in specific industries like medicine where translating text uses quite a lot of different terminology and syntax compared to translating text in casual conversation. Further, it’s possible that API users want to deploy their own custom machine learning models or custom glossaries for translation. And none of these scenarios are well supported by our purely stateless custom method.
在这种情况下,让用户能够创建翻译 ML 模型作为资源,然后将自定义方法附加到该特定资源实际上可能更有意义。通过这样做,方法本身仍然有些无状态,因为自定义方法提供的数据不会导致任何存储的数据。
In this case, it might actually make more sense to provide users the ability to create translation ML models as resources and then attach the custom method to that specific resource. By doing that, the method itself is still somewhat stateless in that no data provided by the custom method results in any stored data.
清单 9.6 附加到 TranslationModel 资源的无状态自定义方法
Listing 9.6 Stateless custom method attached to a TranslationModel resource
abstract class TranslationApi { @post("/translationModels") ❶ CreateTranslationModel(req: CreateTranslationModelRequest): TranslationModel; // ... ❷ @post("/{id=translationModels/*}/text:translate") ❸ TranslateText(req: TranslateTextRequest): TranslateTextResponse; } interface TranslateTextRequest { id: string; ❸ sourceLanguageCode: string; targetLanguageCode: string; text: string; } interface TranslateTextResponse { text: string; }
❶ TranslationModel 资源具有普通资源的所有标准方法。
❶ TranslationModel resources have all the standard methods of normal resources.
❷其他用于管理 TranslationModel 资源的标准方法将放在此处。
❷ The other standard methods for managing TranslationModel resources would go here.
❸ TranslateText 方法以特定的 TranslationModel 资源为目标。
❸ The TranslateText method targets a specific TranslationModel resource.
现在我们已经了解了它是如何工作的,让我们看看如何将它们整合到最终的 API 中定义。
Now that we’ve seen how this works, let’s look at how we might put it all together into a final API definition.
为了最后一个示例,我们将依赖示例电子邮件发送 API。Email在这种情况下,我们有几种管理资源的标准方法,以及相当多的自定义方法来处理状态更改(例如,归档电子邮件)、发送电子邮件、导出用户电子邮件的整个集合,以及能够确定电子邮件地址是否为无状态的自定义方法是有效的。
For the final example, we’ll rely on the example email sending API. In this case, we have several standard methods for managing Email resources, as well as quite a few custom methods to handle things like state changes (e.g., archiving an email), sending emails, exporting an entire collection of a user’s emails, and a stateless custom method that’s capable of determining whether an email address is valid.
Listing 9.1 Final API definition
abstract class EmailApi { static version = "v1"; static title = "Email API"; // ... ❶ @post("/{id=users/*emails/*}:send") ❷ SendEmail(req: SendEmailRequest): Email; @post("/{id=users/*/emails/*}:unsend") ❸ UnsendEmail(req: UnsendEmailRequest): Email; @post("/{id=users/*/emails/*}:undelete") ❹ UndeleteEmail(req: UndeleteEmailRequest): Email; @post("/{parent=users/*}/emails:export") ❺ ExportEmails(req: ExportEmailsRequest): ExportEmailsResponse; @post("/emailAddress:validate") ❻ ValidateEmailAddress(req: ValidateEmailAddressRequest): ValidateEmailAddressResponse; } interface Email { id: string; subject: string; content: string; state: string; deleted: boolean; // ... }
❶所有常规的标准方法(例如,CreateEmail、DeleteEmail 等)都将放在此处。
❶ All the normal standard methods (e.g., CreateEmail, DeleteEmail, etc.) would go here.
❷发送电子邮件的自定义方法会将电子邮件转换为发送状态,延迟几秒钟,连接到 SMTP 服务,并返回结果。
❷ The custom methods to send an email message would transition the email to a sending state, delay for a few seconds, connect to the SMTP service, and return the result.
❸ unsend 方法允许发送操作在 send 方法引入的延迟期间中止。
❸ The unsend method would allow a send operation to abort during the delay introduced by the send method.
❹取消删除方法将更新 Email.deleted 属性,执行标准删除方法的逆操作(有关软删除的更多信息,请参阅第 25 章)。
❹ The undelete method would update the Email.deleted property, performing the inverse of the standard delete method (see chapter 25 for more information on soft deletion).
❺导出方法将获取所有电子邮件数据并将其推送到远程存储位置(有关导入和导出的更多信息,请参阅第 23 章)。
❺ The export method would take all email data and push it to a remote storage location (see chapter 23 for more information on importing and exporting).
❻这种无状态的电子邮件地址验证方法显然是免费的(因为它不依赖于任何父母)。
❻ This stateless email address validation method is clearly free (as it’s not tied to any parent).
在通常,自定义方法是一项基本功能,有助于完善标准方法和任何 API 资源提供的功能。然而,如第 9.1.1 节所述,这些自定义方法的存在往往与 REST 和支持 RESTful API 设计的原则相矛盾。
In general, custom methods are a basic piece of functionality that help round out those provided by the standard methods and the resources for any API. However, as discussed in section 9.1.1, the very existence of these custom methods tends to present a contradiction with REST and the principles underpinning RESTful API design.
正如我们之前详细探讨的那样,没有必要再次这样做,但值得提醒的是,只要提供一组适当的资源,您可以使用自定义方法执行的任何操作都应该可以使用标准方法来实现。换句话说,自定义方法主要是一个中间地带,以确保最简单和最熟悉的资源层次结构也允许非标准交互。
As we explored in detail earlier, it isn’t necessary to do so again, but it is worth a reminder that anything you can do with a custom method should be possible with standard methods exclusively given a proper set of resources. In other words, custom methods are primarily a middle ground to ensure that the simplest and most familiar resource hierarchy also allows for nonstandard interactions.
同样重要的是要记住,自定义方法经常被误用或过度用作拐杖来证明次优资源布局是合理的。设计糟糕的 API 对所有内容都使用自定义方法是很常见的,因为选择的资源和这些资源之间的关系对于 API 的目的来说是完全错误的。发现自己使用自定义方法执行几乎肯定应该由标准方法处理的常见操作的 API 通常是一个不好的迹象。而且,不幸的是,自定义方法可用于隐藏这种不良设计的时间比我们大多数人希望的要长,从而阻止了对 API 的实际改进。因此,完全确定任何新的自定义方法都不会充当管道胶带以避免承认资源布局是至关重要的。错误的。
It’s also important to keep in mind that custom methods are quite often misused or overused as a crutch to justify a suboptimal resource layout. It’s pretty common to see poorly designed APIs using custom methods for everything because the resources chosen and the relationships between those resources are simply wrong for the purpose of the API. An API that finds itself using custom methods for common actions that almost certainly should be handled by standard methods is generally a bad sign. And, unfortunately, custom methods can be used to hide this poor design for longer than most of us would hope, thereby preventing an actual improvement to the API. As a result, it’s critical to be entirely certain that any new custom method is not acting as duct tape to avoid admitting that the resource layout is wrong.
What if creating a resource requires some sort of side effect? Should this be a custom method instead? Why or why not?
When should custom methods target a collection? What about a parent resource?
Why is it dangerous to rely exclusively on stateless custom methods?
自定义方法应该几乎总是使用 HTTPPOST方法,而从不使用PATCH方法。GET如果自定义方法是幂等且安全的,他们可能会使用该方法。
Custom methods should almost always use the HTTP POST method and never use the PATCH method. They might use the GET method if the custom method is idempotent and safe.
Custom methods use a colon (:) character to separate the resource target from the action being performed (e.g., /missiles/1234:launch).
While side effects are forbidden for standard methods, they are permitted for custom methods. They should be used sparingly and documented thoroughly to avoid confusion for users.
In general, custom methods should target a collection when multiple resources from a single collection are involved.
有时,特别是对于计算工作,API 可能会选择依赖无状态自定义方法来执行大部分工作。应谨慎使用此策略,因为有状态很容易最终变得重要并且以后很难引入在。
Sometimes, particularly for computational work, APIs might choose to rely on stateless custom methods to perform the bulk of the work. This strategy should be used cautiously as it’s easy for statefulness to eventually become important and can be difficult to introduce later on.
在大多数情况下,可以快速处理传入的请求,在收到请求后的几百毫秒内生成响应。在响应时间明显更长的情况下,使用相同的 API 结构并要求用户“等待更长时间”的默认行为并不是一个非常优雅的选择。在本章中,我们将探索如何使用长时间运行的操作作为允许 API 方法以异步方式运行并确保较慢的 API 方法(包括我们在第 7 章中看到的标准方法)能够提供快速和一致的方法在后台执行实际工作时进行反馈。
In most cases, incoming requests can be processed quickly, generating a response within a few hundred milliseconds after the request is received. In the cases where responses take significantly longer, the default behavior of using the same API structure and asking users to “just wait longer” is not a very elegant option. In this chapter, we’ll explore how to use long-running operations as a way of allowing API methods to behave in an asynchronous manner and ensuring that the slower API methods (including standard methods we saw in chapter 7) can provide quick and consistent feedback while executing the actual work in the background.
到目前为止,我们探索的所有 API 方法都是即时和同步的。该服务收到一个请求,立即做一些工作,然后发回一个响应。这种特殊的行为方式实际上被硬编码到 API 设计中,因为返回类型是结果,而不是承诺稍后返回结果。
So far, all of the API methods we’ve explored have been immediate and synchronous. The service receives a request, does some work right away, and sends back a response. And this particular manner of behavior is actually hardcoded into the API design as the return type is the result, not some promise to return a result later on down the line.
到目前为止,这是有效的,因为大多数有问题的行为都相对简单,例如查找数据库行并返回结果。但是,当工作变得更复杂、更复杂、资源更密集时会发生什么?这可能是相对较小的事情(例如连接到外部服务),也可能涉及一些繁重的工作(例如处理数 GB 或 TB 的数据)。无论是少量工作还是大量工作,有一点是明确的:依赖相同的 API 设计来实现始终如一的快速行为和可能非常缓慢的行为不太可能很好地工作。
This has worked so far because most of the behavior in question was relatively simple, such as looking up a database row and returning the result. But what happens when the work becomes more involved, more complicated, and more resource intensive? This might be something relatively minor (such as connecting to an outside service) or it might involve some serious heavy lifting (such as processing many gigabytes or terabytes worth of data). Whether it’s a minor or a major amount of work, one thing is clear: relying on the same API design for both consistently quick behavior and potentially very slow behavior is unlikely to work very well.
如果我们对 API 不做任何更改,而方法只需要更长的时间,那么我们最终会陷入一个非常可怕的境地。通常,当我们编写向 API 发送请求的代码时,我们有一个熟悉的“编写、编译、测试”开发周期。如果 API 请求需要很长时间,那么我们可能会认为我们的代码有问题,终止进程,添加一些打印语句(或启动调试器),然后再次运行代码(参见清单 10.1)。如果我们看到它肯定挂在 API 调用上,我们可能会再次经历这个循环,这次调整一些参数以确保我们没有做一些愚蠢的事情。只有在它继续挂在 API 调用上之后,我们才会注意到文档中的一句话说请求可能需要一段时间,我们应该等待它返回。
If we change nothing about the API and the method just takes longer, then we end up in a pretty scary situation. Generally, when we write code that sends requests to an API we have a familiar “write, compile, test” development cycle. If API requests take a long time, then we might think something’s wrong with our code, kill the process, add some print statements (or fire up a debugger), and run the code again (see listing 10.1). If we see that it’s definitely hanging on the API call, we might go through the cycle again, this time tweaking some parameters to make sure that we’re not doing something silly. Only after it continues hanging on the API call do we notice the one sentence in the documentation saying that the request might take a while and that we should just wait on it to return.
Listing 10.1 Adding print statements to monitor progress through code
function main() { let client = ChatRoomApiClient(); let chatRoomName = generateChatRoomName(); console.log(`Generated name ${chatRoomName}`); ❶ let chatRoom = client.CreateChatRoom({ ❷ name: chatRoomName }); console.log(`Created ChatRoom ${chatRoom.id}`); ❶ } main();
❶我们添加了一些 console.log() 语句来确保问题是 CreateChatRoom 方法耗时过长。
❶ We add some console.log() statements to ensure that the problem is the CreateChatRoom method taking a long time.
❷可能由于某些原因创建 ChatRoom 资源需要一段时间。
❷ Perhaps creating ChatRoom resources takes a while for some reason.
在这一点上,我们可能对问题出在哪里更有信心(毕竟,现在我们很确定它不在我们的代码中),但是我们应该等待多长时间才能收到响应?多长时间才够?我们怎么知道什么时候它太长了,值得更详细地研究这个问题?怎样才能看到这项工作的进展呢?如果我们想取消正在完成的工作怎么办?我们当前在本地终止正在运行的进程的策略不会通知服务器它现在所做的所有工作都将被浪费。有一种方法可以让 API 知道它何时应该停止执行所有工作,因为我们不再对结果感兴趣,这将非常有用。
At this point we might feel a bit more confident of where the problem lies (after all, now we’re pretty sure it’s not in our code), but how long should we expect to wait on a response? How long is long enough? How do we know when it’s too long and worth looking into the problem in more detail? How can we see the progress of this work? And what about if we want to cancel the work being done? Our current strategy of killing our running process locally does nothing to inform the server that all the work it’s now doing is just going to go to waste. It’d be pretty useful to have a way of letting the API know when it should just stop doing all that work because we’re no longer interested in the results.
不幸的是,到目前为止我们所看到的 API 设计根本无法完成我们需要的任务。期望现有模式来处理这些长时间运行的 API 调用有点不合理。这就引出了一个显而易见的问题:我们要做什么?这个设计模式的目标就是回答这个问题。
Unfortunately, the API designs we’ve looked at so far are simply not up to the task of what we need. And expecting existing patterns to handle these long-running API calls is a bit unreasonable. This leads to the obvious question: what do we do? The goal of this design pattern is to answer this question.
这个问题并非 Web API 独有。事实上,在程序中本地执行的工作更为常见。我们想要做一些可能需要一段时间的事情,但是,如果可能的话,我们也希望我们的程序在等待那个缓慢的函数时继续做其他工作。这实际上是一个常见的问题,许多现代编程语言已经创建了结构来处理这种异步行为,并使其易于在我们的程序中进行管理。它们的名称因一种语言而异(Python 称它们为 Futures,JavaScript 称它们为 Promises),但目标很简单:开始一些工作但不一定阻塞它。相反,返回一个代表它的占位符的对象。然后,执行工作并允许占位符最终解析(成功获得结果)或拒绝(抛出错误)。
This problem is not unique to web APIs. As a matter of fact, it’s much more common with work being executed locally in a program. We want to do something that might take a while, but, if possible, we also want our program to continue doing other work while it’s waiting on that slow function. This is actually such a common problem that many modern programming languages have created constructs to handle this asynchronous behavior and make it easy to manage in our programs. They vary in name from one language to another (Python calls them Futures, JavaScript calls them Promises), but the goal is simple: start some work but don’t necessarily block on it. Instead, return an object that represents a placeholder for it. Then, perform the work and allow the placeholder to eventually either resolve (succeed with a result) or reject (throw an error). To handle these different scenarios, users can either register callbacks on the placeholder that will handle the result asynchronously or wait on the result to be returned, blocking code execution until resolution or rejection of that placeholder (effectively making the asynchronous work synchronous).
Listing 10.2 Awaiting and attaching callbacks to promises
async function factor(value: number): Promise<number[]> { // ... } async function waitOnPromise() { let factors = await factor(...); ❶ let promise = factor(...); let otherFactors = await promise; ❶ } function promiseCallback() { let promiseForFactors = factor(...); ❷ promiseForFactors.then( (factors: number[]) => { // ... }).catch( (err) => { // ... }); }
❶ Here we can await the result. This will either return a result or raise an error.
❷在这里我们可以将回调附加到 promise,处理正在解决的结果和拒绝的错误。
❷ Here we can attach callbacks to the promise, handling both the result being resolved and an error from a rejection.
此模式的目标是为 Web API 设计一个等效的 Promises 或 Futures,我们将其称为长时间运行的操作或 LRO。这些 API 承诺主要侧重于提供一种工具来跟踪 API 在后台所做的工作,并且应该支持类似的交互模式,例如等待(阻塞)结果、检查状态或异步通知结果。在许多情况下,这些 LRO 还提供暂停、恢复或取消正在进行的操作的能力,但是,正如我们将看到的,这将取决于具体情况。图 10.1 显示了 LRO 的示例及其在系统中的流动方式。
The goal of this pattern is to design an equivalent of Promises or Futures for web APIs, which we’ll call long-running operations or LROs. These API promises are primarily focused on providing a tool to track work the API does in the background and should support similar interaction patterns such as waiting (blocking) on the results, checking on the status, or being notified asynchronously of the results. In many cases, these LROs also provide the ability to pause, resume, or cancel the ongoing operation, but, as we’ll see, that will depend on the circumstances. An example of an LRO and how it flows through the system is shown in figure 10.1.
Figure 10.1 Life cycle of an LRO to backup data
虽然 LRO 与 promises 有很多相似之处,但也有很多很大的不同。首先,由于 LRO 负责跟踪另一个 Web API 方法完成的工作,因此 LRO 只是这些不同方法的新返回类型。与可以显式创建以在后台执行任意代码的编程语言中的承诺不同,很少有任何理由显式创建 LRO。
While LROs will have quite a few similarities to promises, there will also be many big differences. First, since LROs are responsible for tracking the work done by another web API method, LROs are simply a new return type for these various methods. Unlike promises in programming languages, which can be explicitly created to execute arbitrary code in the background, there is rarely any reason to explicitly create an LRO.
Listing 10.3 A standard create method that returns an LRO
abstract class ChatRoomApi { @post("/chatRooms") CreateChatRoom(req: CreateChatRoomRequest): Operation<ChatRoom, CreateChatRoomMetadata>; ❶ }
❶标准的 create 方法不是直接返回 ChatRoom 资源,而是返回一个最终会生成 ChatRoom 资源的 LRO。
❶ Rather than returning a ChatRoom resource directly, the standard create method returns an LRO that will eventually result in a ChatRoom resource.
另一个重要区别在于这些 LRO 的持久性。虽然 promises 是短暂的并且仅存在于创建它们的代码的运行过程的上下文中,但根据定义,LRO 是一个远程概念,它在 API 服务器的远程执行环境中存在和运行。因此,它们需要被视为适当的 API 资源,并使用它们自己的标识符持久保存在 API 中。换句话说,即使创建它们的过程早已被丢弃,这些 LRO 仍将存在。
Another important difference is in the persistence of these LROs. While promises are ephemeral and exist only in the context of the running process of the code that created them, LROs are, by definition, a remote concept that live and operate in the remote execution environment of the API server. As a result, they’ll need to be treated as proper API resources, persisted in the API with identifiers all of their own. In other words, these LROs will exist even after the process that created them has long been discarded.
此外,与简单返回特定类型结果的承诺不同,LRO 将用于跟踪进度和有关操作本身的其他元数据。因此,LRO 还必须提供某种模式来存储这种操作元数据。这可能类似于操作的进度、操作开始或完成的时间,或者作为操作的一部分正在执行的当前操作。
Further, unlike promises that simply return a result of a specific type, LROs will be used to track progress and other metadata about the operation itself. As a result, LROs must also provide some sort of schema for storing this operational metadata. This might be something like progress of the operation, the time at which the operation was started or completed, or the current action being undertaken as part of the operation.
最后,由于这些 LRO 可以存活任意长的时间并且不会随着原始进程而消亡,因此我们很容易失去对Operation资源的控制. 由于我们不能始终确定我们会永久记录每个 LRO 的标识符,因此我们还需要一种方法来发现 API 知道的 LRO。为此,我们将依赖 API 服务和一些标准方法来查找我们创建的 LRO,例如ListOperations().
Finally, since these LROs can live for an arbitrary amount of time and do not die with the originating process, it’s quite easy to lose our handle on an Operation resource. Since we can’t always be sure we’ll have a persistent record of the identifier of every LRO, we’ll also need a way of discovering the LROs the API knows about. To do this, we’ll rely on an API service with some of the standard methods for finding LROs we’ve created, such as ListOperations().
现在我们已经了解了 LRO 应该如何工作的基本概念,让我们更深入地了解 LRO 的外观以及我们如何将这个概念集成到网络中的细节应用程序接口。
Now that we have the basic idea behind how LROs should work, let’s dig deeper into the details of what LROs look like and how we can integrate the concept into a web API.
到添加对依赖于 LRO 的异步 API 调用的支持,我们需要做两件事。首先,我们必须定义这些Operation资源的外观。这将包括允许对 LRO 的结果类型(例如,最终将返回的资源)以及我们可能想要存储的关于操作的任何元数据(例如,进度指示器)的接口进行某种形式的通用参数化以及有关 LRO 本身的其他信息)。
To add support for asynchronous API calls that rely on LROs, we’ll need to do two things. First, we must define what these Operation resources look like. This will include allowing some form of generic parameterization for both the result type of the LRO (e.g., the resource that will ultimately be returned) as well as the interface for any metadata about the operation that we may want to store (e.g., progress indicators and other information about the LRO itself).
其次,由于 LRO 与创建它们的环境不同(API 服务与客户端代码),我们需要一种在 API 中发现和管理这些 LRO 的方法。这意味着我们需要定义几个 API 方法来与 LRO 交互,主要是标准列表方法、标准获取方法,甚至可能是用于暂停、恢复和取消操作的自定义方法。让我们从定义 LRO 的外观开始。
Second, since LROs live in a different environment from where they are created (the API service versus the client code), we need a way of discovering and managing these LROs in the API. This means we’ll need to define several API methods to interact with LROs, primarily a standard list method, standard get method, and potentially even custom methods for pausing, resuming, and canceling operations. Let’s start by defining what an LRO looks like.
到在了解 LRO 接口应该是什么样子之前,我们需要考虑我们想用这个资源做什么。首先,由于它是一种资源,我们需要能够与其进行交互,从而导致必需的标识符字段。接下来,这些 LRO 的主要目标是最终返回一个结果。这意味着我们显然需要一种表示该结果的方法。但是,我们还必须考虑发生错误且操作未成功完成的情况。此外,我们必须考虑这样一个事实,即可能有一些操作根本不会有结果,而只是通过不存在错误来指示成功。为了处理所有这些情况,我们可以使用一个可选的结果字段,它可以是一个OperationError接口(带有代码编号、消息文本和其他详细信息)或参数化结果类型。对于最后一部分,我们可以依赖 TypeScript 的泛型,接受输入泛型类型ResultT.
To understand what an LRO interface should look like, we need to consider what we want to do with this resource. First, since it is a resource, we need the ability to interact with it, leading to a required identifier field. Next, the main goal of these LROs is to ultimately return a result. This means that we clearly need a way of representing that result. However, we also have to consider the cases where an error occurs and the operation doesn’t complete successfully. Additionally, we must consider the fact that there may be some operations that will simply not have a result and instead success is indicated simply by no error being present. To handle all of these cases, we can use an optional result field, which can either be an OperationError interface (with a code number, message text, and additional details) or a parameterized result type. And for this last piece we can rely on TypeScript’s generics, accepting an input generic type of ResultT.
此外,我们必须考虑 LRO 状态的一些概念。在许多语言中,promises 具有三种可能的状态:pending、resolved(成功完成)或 rejected(完成但出现错误结果)。虽然我们可以从不存在的结果字段推断出 LRO 的状态,但是当最终结果只是空的时候,这可能容易出错。为此,Operation资源应该有一个单独的布尔标志来指示 LRO 是否完成。为避免该字段代表操作成功或失败的任何指示(例如,complete可能指示资源已成功完成),我们将简单地调用此布尔标志done.
Additionally, we must consider some concept of the status of the LRO. In many languages, promises have three potential states: pending, resolved (successfully completed), or rejected (completed with an error result). While we may be able to infer the status of the LRO from the result field not being present, this may be error prone when the end result is simply empty. To help with this, the Operation resource should have a separate Boolean flag to indicate whether the LRO is complete. To avoid any indication that this field represents the success or failure of the operation (e.g., complete might indicate that the resource completed successfully), we’ll simply call this Boolean flag done.
最后,我们必须考虑这样一个事实,即这些 LRO 可能需要在结果可用之前提供有关正在执行的工作的元数据。例如,也许我们想为用户提供进度指示器或估计的剩余时间值。与其简单地为这个元数据公开一个无模式的值,不如接受一个接口作为这个值的通用参数,称为MetadataT.
Finally, we must consider the fact that these LROs may need to provide metadata about the work being performed before a result is available. For example, perhaps we want to provide users with a progress indicator or an estimated time remaining value. Rather than simply exposing a schema-less value for this metadata, we can instead accept an interface as a generic parameter for this value, called MetadataT.
清单 10.4 Operation 和 OperationError 接口的定义
Listing 10.4 Definition of the Operation and OperationError interfaces
interface Operation<ResultT, MetadataT> { ❶ id: string; done: boolean; ❷ result?: ResultT | OperationError; ❶ metadata?: MetadataT; ❶ } interface OperationError { code: string; message: string; details?: any; }
❶我们使用 ResultT 和 MetadataT 泛型来定义结果类型的接口以及关于 LRO 存储的元数据。
❶ We use the ResultT and MetadataT generics to define the interface of the result type as well as for metadata stored about the LRO.
❷我们依靠 done 字段来指示 Operation 是否仍在工作。
❷ We rely on the done field to indicate whether an Operation is still doing work.
值得一提的是,没有真正要求ResultT是 API 资源的想法。在某些情况下,这可能是一个空值,例如当某些工作除了已经完成之外没有要报告的实际结果时。它也可能是某种临时值,例如TranslationResult接口这可能会产生将文本从一种语言翻译成另一种语言的请求的结果。此外,如果没有任何元数据值得存储在 LRO 上,则显然可以将此类型设置为null, 有效地完全消除了这个领域。
It’s worth calling out the idea that there’s no real requirement that the ResultT be an API resource. In some cases this might be an empty value, such as when some work has no real results to report other than the work has been done. It also might be some sort of ephemeral value, such as a TranslationResult interface that may have the results of a request to translate text from one language to another. Further, if there’s no metadata ever worth storing on the LRO, this type can obviously be set to null, effectively eliminating the field entirely.
现在我们有了这个 LRO 类型,我们必须实际使用它。为此,我们只需将其作为 API 方法的结果返回即可。例如,正如我们在清单 10.3 中看到的那样,我们可以通过返回一种类型Operation <ChatRoom, CreateChatRoomMetadata>而不是ChatRoom像我们在第 7 章中学到的那样使标准的创建方法异步。然而,在幕后,我们需要创建和存储这个Operation资源,这导致了一个有趣的问题,即 LRO 在资源中的位置等级制度。
Now that we have this LRO type, we have to actually use it. To do this, we simply return it as the result of an API method. For example, as we saw in listing 10.3, we can make a standard create method asynchronous by returning a type of Operation <ChatRoom, CreateChatRoomMetadata> rather than just ChatRoom as we learned in chapter 7. Under the hood, however, we need to create and store this Operation resource, which leads to an interesting question about the location of LROs in the resource hierarchy.
自从由异步方法创建的 LRO 必须保存在某个地方,这带来了一个重要且明显的问题:这些 LRO 究竟应该存在于资源层次结构中的什么位置?我们有两个明显的选择可供选择。首先,我们可以将操作集合存储在 LRO 运行的特定资源下。或者,我们可以将操作存储为顶级集合,与它们可能涉及的资源分开并分开。
Since the LROs created by asynchronous methods must be persisted somewhere, it brings an important and obvious question: where exactly in the resource hierarchy should these LROs live? We have two obvious options to choose from. First, we could store the collection of operations underneath the specific resource on which the LRO is operating on. Alternatively, we could store operations as a top-level collection, separate and apart from the resources they might be involved with.
在使用 LRO 使标准方法异步的情况下(如清单 10.3 所示),这可能非常有意义。然而,尽管这个选项可能很诱人,但它也带来了很多问题。首先,如果操作不一定以资源为中心怎么办?相反,如果操作是更短暂的操作,例如将一些音频转录成文本,该怎么办?其次,如果我们将这些集合拆分到各种资源(例如,chatRooms/1/operations/2与chatRooms/2/operations/3),这会阻止我们轻松查询系统中的整个操作列表。
In cases where LROs are being used to make standard methods asynchronous (as seen in listing 10.3), this might make perfect sense. However, as tempting as this option might be, it presents quite a few problems. First, what if the operation isn’t necessarily centered on a resource? What if, instead, the operation is something more ephemeral such as transcribing some audio into text? Second, if we split these collections across a variety of resources (e.g., chatRooms/1/operations/2 versus chatRooms/2/operations/3), this prevents us from being able to easily query the entire list of operations in the system.
由于所有这些原因,使用集中的顶级Operation资源集合(例如,/operations/1)是一个更好的主意。通过这种类型的集中式资源布局,正如我们将在后面的部分中看到的那样,除了一次简单地处理单个 LRO 之外,我们还能够发现整个系统中的其他 LRO。
Because of all these things, it’s a far better idea to use a centralized top-level collection of Operation resources (e.g., /operations/1). With this type of centralized resource layout, as we’ll see in later sections, we have the ability to discover other LROs across the system in addition to simply addressing a single LRO at a time.
现在我们已经了解了这些 LRO 资源的存放位置,让我们看看我们如何实际使用它们实践。
Now that we have an idea of where these LRO resources will live, let’s look at how we actually go about using them in practice.
所以到目前为止,我们已经注意到 API 方法可以通过简单地更改返回类型快速变为异步。这种从即时结果到最终返回结果的 LRO 的转变无疑是处理此类问题的一种快速简便的方法;然而,我们真的没有说任何关于我们如何处理Operation返回给我们的资源的事情。换句话说,一旦我们获得了这个 LRO 资源,我们究竟应该用它做什么呢?我们如何得到最终的结果?
So far, we’ve noted that API methods can quickly become asynchronous by simply changing the return type. This shift from an immediate result to an LRO that will eventually return a result is certainly a quick and easy way to handle these types of issues; however, we’ve really not said anything about how we deal with this Operation resource that’s returned to us. In other words, once we get this LRO resource, what exactly are we supposed to do with it? How do we get the final result?
在大多数编程语言中,承诺可以以两种状态之一结束:拒绝或解决。这种解决行为,其中承诺成功完成并以返回最终值结束,可以说是承诺(或者在我们的例子中,操作)中最重要的部分。那么我们如何获得这个结果呢?事实证明有很多方法可以做到这一点,正如我们在清单 10.2 中看到的那样。但是,为了使这些在本地解决承诺的样式适应 Web API 的世界,我们需要进行一些调整。
In most programming languages, promises can end with one of two states: rejected or resolved. This act of resolution, where a promise completes successfully and ends with the final value being returned, is arguably the most important part of a promise (or, in our case, an operation). So how do we go about getting this result? It turns out there are quite a few ways to do this, as we saw in listing 10.2. However, in order to adapt these styles of resolving a promise locally to the world of web APIs, we need to make a few adjustments.
在接下来的几节中,我们将研究确定 LRO 最终结果的两种不同方法,首先是轮询结果。
In the following few sections, we’ll look at the two different ways we can go about determining the end result of an LRO, starting with polling for a result.
这确定 LRO 是否仍在运行或已完成的最直接方法是简单地询问有关 LRO 的服务。换句话说,我们可以不断地不断地请求 LRO 资源,直到它的done领域设置为true。事实上,许多依赖于 LRO 之类的客户端库将在后台执行此检查,或者在请求之间使用标准等待时间,或者使用某种形式的指数退避,从而在每个请求之间增加越来越多的时间。
The most straightforward way to determine whether an LRO is still running or has completed is to simply ask the service about the LRO. In other words, we can continually request the LRO resource over and over until its done field is set to true. In fact, many client libraries that rely on something like LROs will perform this check under the hood, either with a standard wait time between requests or with some form of exponential back-off, adding more and more time between each request.
Listing 10.5 Polling for updates about an LRO until it’s done
async function createChatRoomAndWait(): Promise<ChatRoom> { let operation: Operation<ChatRoom, CreateChatRoomMetadata>; operation = CreateChatRoom({ ❶ resource: { title: "Cool Chat" } }); while (!operation.done) { ❷ operation = GetOperation<ChatRoom, ➥ CreateChatRoomMetadata>({ ❸ id: operation.id }); await new Promise( (resolve) => { ❹ setTimeout(resolve, 1000) }); } return operation.result as ChatRoom; ❺ }
❶ CreateChatRoom 方法不返回 ChatRoom,而是返回一个 Operation,这是一个最终解析为 ChatRoom(或 OperationError)的承诺。
❶ Instead of returning a ChatRoom, the CreateChatRoom method returns an Operation, which is a promise to eventually resolve to a ChatRoom (or an OperationError).
❷只要操作的 done 字段仍然为 false,我们就继续循环。
❷ We continue the loop as long as the operation’s done field is still false.
❸ Inside each iteration, we request the operation information all over again.
❹我们等待一段时间过去,以便在 API 服务器上进行更多工作。
❹ We wait for some time to pass, allowing more work to happen on the API server.
❺ Finally, we return the operation result.
在这种情况下,我们需要存在的只是Operation在响应中返回资源的方法(在本例中为CreateChatRoom),然后是参数化GetOperation方法检索 LRO 的状态。
In this case, all we need to exist is the method that returns the Operation resource in the response (in this case, CreateChatRoom) and then the parameterized GetOperation method to retrieve the status of the LRO.
Listing 10.6 Definition of the standard get method for LROs
abstract class ChatRoomApi { @post("/chatRooms") CreateChatRoom(req: CreateChatRoomRequest): Operation<ChatRoom, CreateChatRoomMetadata>; @get("/{id=operations/*}") GetOperation<ResultT, MetadataT>(req: GetOperationRequest): Operation<ResultT, MetadataT>; ❶ } interface GetOperationRequest { id: string; }
❶方法参数化,保证返回类型正确。此方法将用于所有操作类型,而不仅仅是 CreateChatRoom 方法。
❶ The method is parameterized to ensure the correct return type. This method will be used for all operation types, not just the CreateChatRoom method.
这种行为的明显缺点是几乎可以肯定会有一长串请求只会导致我们再次尝试,这可能被认为是浪费精力(客户端和 API 服务器上的网络流量和计算时间)。这显然有点不方便,但最容易理解,确保我们及时听到结果(根据我们自己对“及时”的定义),并将询问结果的责任留给客户,谁最了解他们对结果感兴趣的时间范围。
The obvious downside of this type of behavior is that there will almost certainly be a long list of requests that simply result in us trying again, which might be considered wasted effort (network traffic and compute time on both the client and the API server). This is clearly a bit inconvenient, but it is the simplest to understand, ensures that we hear about the results in a timely manner (according to our own definition of “timely”), and leaves the onus of asking for the results on the client, who is best informed about the timeframe in which they become interested in the results.
有了这个,让我们进入下一个选项来找出 LRO 的结果:等待。
With that, let’s move onto the next option for finding out the result of an LRO: waiting.
轮询有关 LRO 的更新将控制权交到客户手中。换句话说,客户端决定检查操作的频率,并且可以决定以任何理由停止检查,例如不再对操作结果感兴趣。轮询的一个缺点是我们不太可能立即知道操作的结果。通过减少请求操作更新之间的等待时间,我们总是可以越来越接近立即发现,但这会导致越来越多的请求根本没有更新。本质上,轮询在我们进行更新之前的最大延迟与浪费的时间和资源量之间有一个内在的权衡。
Polling for updates about an LRO puts the control in the hands of the client. In other words, the client decides how frequently to check on the operation and can decide to stop checking for any reason at all, such as no longer being interested in the result of the operation. One drawback of polling is that it’s very unlikely that we’ll know about the results of the operation immediately. We can always get closer and closer to finding out immediately by reducing the time we wait between requesting an update on the operation, but this results in more and more requests with no updates at all. In essence, polling has an intrinsic trade-off between the maximum delay before we have an update and the amount of wasted time and resources.
解决此问题的一种方法是依赖与 API 服务的长期连接,该连接仅在操作完成后关闭。换句话说,我们向 API 服务发出请求,要求保持连接打开,并且仅在操作的done字段设置为时才响应true。这种类型的阻塞请求确保 API 服务知道操作完成的那一刻,我们将被告知该操作的结果。尽管服务器端的实现可能实际上依赖于轮询,但在 API 服务器内部进行轮询比从远程客户端到 API 服务器的轮询要简单得多。
One way to get around this is to rely on a long-lived connection to the API service that closes only once the operation is complete. In other words, we make a request to the API service asking to keep the connection open and only respond when the operation has its done field set to true. This type of blocking request ensures that the moment that the API service knows an operation is complete, we’ll be informed about the result of that operation. And even though it’s possible that the implementation of this on the server side may actually rely on polling, polling inside the API server is much simpler than polling from a remote client to the API server.
这个选项的缺点是现在 API 服务器负责管理所有这些连接并确保在操作完成时对请求发出响应(这可能需要相当长的时间)。这可能会变得相当复杂,但最终结果是为 API用户提供了一个非常简单的交互模式。
The downside of this option is that now the API server is responsible for managing all of these connections and ensuring that responses are issued to the requests as operations complete (which could take quite some time). This can become quite complex, but the end result is a very simple interaction pattern for API users.
为了让它工作,我们需要添加一个新的自定义方法:wait。此方法看起来与GetOperation参数化方法几乎相同,但它不会立即返回状态,而是等到操作被解决或拒绝。
To make this work, we need to add a new custom method: wait. This method looks almost identical to the GetOperation parameterized method, but instead of returning the status right away, it will wait until the operation is resolved or rejected.
Listing 10.7 Definition of the custom wait method for LROs
abstract class ChatRoomApi { @get("/{id=operations/*}:wait") ❶ WaitOperation<ResultT, MetadataT>(req: WaitOperationRequest): Operation<ResultT, MetadataT>; } interface WaitOperationRequest { id: string; }
❶这里我们使用 HTTP GET 方法,因为这应该是幂等的。
❶ Here we use the HTTP GET method as this should be idempotent.
值得注意的是,由于输入和返回类型在WaitOperation和GetOperationGetOperation在等待或不等待的方法上添加一个简单的标志可能会更容易。虽然这肯定是对空间的更有效利用,但这意味着单个布尔标志可以有意义地改变 API 方法的底层行为,这通常是一个非常糟糕的主意。此外,通过使用单独的方法,我们可以轻松监控和执行服务级别目标,例如预期响应时间。如果我们使用一个标志来指示我们是否应该等待解决方案,那么将很难区分缓慢的 API 调用,因为它们正在等待GetOperation由于基础结构问题而缓慢的即时方法。
It’s worth noting that since the input and return types are identical between WaitOperation and GetOperation it might have been easier to add a simple flag on the GetOperation method to wait or not. While this is certainly a more efficient use of space, it means that a single Boolean flag can meaningfully change the underlying behavior of the API method, which is generally a very bad idea. Further, by using a separate method we make it simple to monitor and enforce service-level objectives like the expected response time. If we use a flag to indicate whether we should wait on the resolution, it will be quite difficult to disentangle API calls that are slow because they’re being waited on from immediate GetOperation methods that are slow because of an infrastructural problem.
这在客户端代码中如何工作?简而言之,客户端只需等待 API 调用的响应即可。换句话说,此方法有效地使任何异步 API 调用都像同步调用一样。有了WaitOperation方法,我们的createChatRoomAndWait()功能变得简单得多。
How does this work in client-side code? In short, the client can simply wait on the response of the API call. In other words, this method is effectively making any asynchronous API call act like a synchronous call. With the WaitOperation method, our createChatRoomAndWait() function becomes far simpler.
清单 10.8 使用 WaitOperation 自定义方法的客户端代码
Listing 10.8 Client code using the WaitOperation custom method
function createChatRoomAndWait(): Promise<ChatRoom> { let operation: Operation<ChatRoom, CreateChatRoomMetadata>; operation = CreateChatRoom({ ❶ resource: { title: "Cool Chat" } }); operation = WaitOperation({ id: operation.id }); ❷ return operation.result as ChatRoom; ❸ }
❶我们可以使用标准的 create 方法获得创建 ChatRoom 资源的承诺。
❶ We can obtain a promise to create a ChatRoom resource by using the standard create method.
❷ Instead of a loop, we can simply wait on the result of the operation.
❸假设没有错误,一旦 WaitOperation 方法返回结果,结果就可用。
❸ Assuming there were no errors, the result is available once the WaitOperation method returns a result.
虽然这肯定会导致直接、同步的客户端代码,但等待响应充满了潜在的问题。如果连接由于某种原因丢失(例如,客户端可能是一个可能会丢失信号的移动设备),我们又回到了开始必须轮询的地方(一种由外部环境驱动的不同类型的轮询,而不是比计时事件)。
While this certainly leads to straightforward, synchronous client-side code, waiting on responses is fraught with potential issues. If a connection is lost for one reason or another (e.g., perhaps the client is a mobile device that may lose its signal), we’re back where we started with having to poll (a different type of polling that’s driven by external circumstances rather than timing events).
现在我们已经了解了如何检索 LRO 的结果,让我们看看(希望如此)不太常见的情况,即结果不成功,而我们需要处理一个错误。
Now that we’ve looked at how to retrieve the result of an LRO, let’s look at the (hopefully) less common scenario where the result is not successful and we instead need to handle an error.
在对于大多数 Web API,错误以 HTTP 错误代码和消息的形式表现出来。例如,我们可能会收到一条404 Not Found错误消息,指示未找到资源,或者403 Forbidden错误消息表明我们无权访问资源。当响应是即时和同步的时,这很有效,但我们如何在 LRO 的异步世界中处理这个问题?
In most web APIs, errors manifest themselves in the form of HTTP error codes and messages. For example, we might get a 404 Not Found error indicating that a resource wasn’t found, or a 403 Forbidden error indicating we don’t have access to a resource. This works well when the response is immediate and synchronous, but how do we handle this in the asynchronous world of LROs?
当 LRO 失败时简单地传递错误响应当然很诱人,但这可能会带来一些严重的问题。例如,如果对 的响应GetOperation产生底层操作的错误,我们如何区分500 Internal Server Error操作的结果和相同的错误这实际上只是处理该GetOperation方法的代码的错误?显然,这意味着我们需要一个替代方案。
While it’s certainly tempting to simply pass along the error response when an LRO has failed, this could present some serious problems. For instance, if the response to GetOperation yields the error of the underlying operation, how can we tell the difference between a 500 Internal Server Error that was the result of the operation and the same error that is actually just the fault of the code that handles the GetOperation method? Clearly this means we’ll need an alternative.
正如我们之前看到的,我们处理这个问题的方式包括允许结果(指定ResultT类型) 或OperationError类型,其中包括机器可读代码、供人类使用的友好描述以及用于存储其他错误详细信息的任意结构。这意味着只有在检索资源GetOperation时出现实际错误时,该方法才应抛出异常(通过返回 HTTP 错误) 。Operation如果Operation资源检索成功,错误响应只是 LRO 结果的一部分。然后由客户端代码决定是抛出客户端异常还是以其他方式处理错误结果。比如我们可以调整我们的createChatRoomAndWait()函数处理可能的错误结果。
As we saw previously, the way we’ll handle this involves allowing either a result (of the indicated ResultT type) or an OperationError type, which includes both a machine-readable code, a friendly description for human consumption, and an arbitrary structure for storing additional error details. This means that the GetOperation method should only throw an exception (by returning an HTTP error) if there was an actual error in retrieving the Operation resource. If the Operation resource is retrieved successfully, the error response is simply a part of the result of the LRO. It’s then up to the client code to decide whether to throw a client-side exception or handle the error result another way. For example, we can adjust our createChatRoomAndWait() function to handle a possible error result.
Listing 10.9 Checking for an error or returning the result
function isOperationError(result: any): result is OperationError { ❶ const err = result as OperationError; return err.code !== undefined && err.message !== undefined; } function createChatRoomAndWait(): Promise<ChatRoom> { let operation: Operation<ChatRoom, CreateChatRoomMetadata>; operation = CreateChatRoom( { resource: { title: "Cool Chat" } }); operation = WaitOperation({ id: operation.id }); if (isOperationError(operation.result)) { ❷ // ... } else { return operation.result as ChatRoom; } }
❶ First we define a type-checking function to figure out whether the result is an error.
❷ Next we can check the result and do different things depending on whether the operation succeeded.
在我们继续之前,值得强调的是错误代码值是唯一的、特定的并且是为机器而不是人类使用的是多么重要。如果未提供错误代码(或有重叠,或旨在供人类而不是计算机使用),我们最终会陷入可怕的境地,即 API 用户可能开始依赖消息内容来找出问题所在。这本身不是问题(毕竟,错误消息可以帮助用户找出问题所在);然而,每当 API 用户觉得需要编写任何依赖于该错误消息内容的代码时,它就会突然成为 API 的一部分。这意味着当我们更改错误消息(例如,通过修复拼写错误)时,我们可能会无意中导致以前工作的代码出现错误。正如我们将在第 24 章中学到的,应该不惜一切代价避免这种情况,
Before we move on, it’s worth stressing how important it is that the error code value is unique, specific, and intended for machine and not human consumption. When error codes are not provided (or have overlap, or are intended for humans rather than computers), we end up in the scary situation where API users may begin to rely on the message contents to figure out what’s wrong. This isn’t a problem by itself (after all, error messages are there to help users figure out what went wrong); however, whenever an API user feels the need to write any code that relies on the content of that error message, it has suddenly become part of the API. This means that when we change the error message (say, by fixing a typo), we can unintentionally cause errors in previously working code. As we’ll learn in chapter 24, this should always be avoided at all costs, and the best way to do that is to have error codes that are far more useful in code than error messages themselves.
在用户不仅需要知道错误类型,还需要一些关于错误的附加信息的情况下,详细信息字段是放置这种结构化的、机器可读信息的最佳位置。虽然此附加信息并未严格禁止出现在错误消息中,但它在错误详细信息中的用处要大得多,而且肯定是必需的。一般来说,如果错误消息丢失,我们不应该丢失任何无法在 API 文档中查找的信息。
In the cases where users need not only to know the error type, but also some additional information about the error, the details field is the perfect place to put this structured, machine-readable information. While this additional information is not strictly prohibited from being in the error message, it’s far more useful in the error details and is certainly required. In general, if the error message was missing, we should not lose any information that can’t be looked up in the API documentation.
有了这个,让我们深入研究 LRO 的下一个有趣但可选的方面:监控进步。
With that, let’s dig into the next interesting, but optional, aspect of LROs: monitoring progress.
向上到目前为止,我们已经将操作视为已完成或未完成,但我们并没有真正关注这两种状态之间的点。那么如何才能时刻关注LRO的进展呢?这正是Operation资源的元数据字段发挥作用的地方。
Up until now, we’ve looked at operations as either done or not done, but we haven’t really looked much at the points in between those two states. So how can we keep an eye on the progress of an LRO? This is exactly where the metadata field of the Operation resource comes into play.
正如我们在清单 10.4 中看到的,Operation资源接受两个字段的两种类型。第一个是结果的类型,第二个MetadataT是分配给元数据字段的类型。正是这个特殊的接口,我们可以用来存储关于 LRO 本身的信息,而不是关于结果的信息。这可以是简单的信息,例如工作开始时的时间戳,或者在这种情况下,有关正在完成的工作的进度、完成前的估计剩余时间或已处理的字节数。
As we saw in listing 10.4, the Operation resource accepts two types for two fields. The first was the type of the result, the second was a MetadataT that was assigned to the metadata field. It’s this special interface we can use to store information not about the result but about the LRO itself. This could be simple information such as the timestamp of when the work began or, in this case, progress about the work being done, estimated time left until completion, or number of bytes processed.
虽然进展通常意味着“完成百分比”,但并没有明确要求必须如此。在许多情况下,百分比可能有用,但估计的完成时间更有用,或者可能是对实际进度的衡量,例如处理的记录。此外,不能保证我们会有一个有意义的百分比值——我们都知道完成百分比开始倒退是多么令人沮丧!幸运的是,我们可以为元数据字段使用我们想要的任何接口。清单 10.10 显示了一个示例,用于分析某个ChatRoom资源的对话历史以获取某些语言统计信息,使用到目前为止已分析和计数的消息数作为进度指标。
While progress often means “percent completed,” there’s no firm requirement that this is the case. In many scenarios a percentage might be useful, but an estimated completion time is even more useful, or perhaps a measurement of the progress in real terms, such as records processed. Further, there’s no guarantee that we’ll have a meaningful percentage value—and we all know how frustrating it can be for a percent complete to start going backward! Luckily, we can use any interface we want for the metadata field. Listing 10.10 shows an example analyzing the conversation history of a ChatRoom resource for some linguistic statistics, using the number of messages analyzed and counted so far as progress indicators.
清单 10.10 具有元数据类型的 AnalyzeMessages 自定义方法的定义
Listing 10.10 Definition of the AnalyzeMessages custom method with metadata type
abstract class ChatRoomApi { @post("/{parent=chatRooms/*}/messages:analyze") AnalyzeMessages(req: AnalyzeMessagesRequest): Operation<MessageAnalysis, AnalyzeMessagesMetadata>; } interface AnalyzeMessagesRequest { parent: string; } interface MessageAnalysis { ❶ chatRoom: string; messageCount: number; participantCount: number; userGradeLevels: map<string, number>; } interface AnalyzeMessagesMetadata { ❷ chatRoom: string; messagesProcessed: number; messagesCounted: number; }
❶该方法的结果不是资源,而是显示每个用户的写作等级等的分析界面。
❶ The result of this method is not a resource, but an analysis interface showing grade levels of writing per user, among other things.
❷ The metadata interface uses messages processed and counted to determine progress.
我们如何实际使用它?在 10.3.3 节中,我们了解了如何使用轮询来持续检查 LRO,以期它已完成。同样的方式,我们可以使用该GetOperation方法来检索存储在AnalyzeMessagesMetadata界面中的进度信息.
How do we actually use this? In section 10.3.3, we saw how to use polling to continually check on an LRO in the hopes that it was completed. In this same manner, we can use the GetOperation method to retrieve progress information stored in the AnalyzeMessagesMetadata interface.
Listing 10.11 Showing progress using the metadata interface
async function analyzeMessagesWithProgress(): Promise<MessageAnalysis> { let operation: Operation<MessageAnalysis, AnalyzeMessagesMetadata>; operation = AnalyzeMessages({ parent: 'chatRooms/1' }); while (!operation.done) { operation = GetOperation<MessageAnalysis, AnalyzeMessagesMetadata>({ id: operation.id }); let metadata = result.metadata as ➥ AnalyzeMessagesMetadata; ❶ console.log( `Processed ${metadata.messagesProcessed} of ` + ❷ `${metadata.messagesCounted} messages counted...`); await new Promise( (resolve) => setTimeout(resolve, 1000)); } return operation.result as MessageAnalysis; }
❶ First we retrieve the metadata from the operation at its current state.
❷ Next we print a line to the console updating the progress of messages as they’re processed and counted.
现在我们知道如何检查操作的进度,我们可以开始探索可以与操作交互的其他方式。例如,如果操作的进度比我们预期的要慢得多怎么办?也许我们犯了一个错误?我们怎样才能取消手术?
Now that we know how to check the progress of an operation, we can start exploring other ways we can interact with operations. For example, what if the progress of an operation is going far slower than we had anticipated? Perhaps we made a mistake? How can we cancel the operation?
所以到目前为止,我们与 LRO 的所有交互都假定,一旦开始,操作将继续执行它们的工作,直到它们成功解决或因错误而拒绝。但是如果我们不想等待操作完成怎么办?
So far, all of our interactions with LROs have assumed that, once started, operations continue executing their work until they either resolve successfully or reject with an error. But what if we don’t want to wait for the operation to complete?
出于多种原因,我们可能想要这样做。拼写错误和“脑筋急转弯”(拼写错误的心理版本)发生的频率远比我们愿意承认的要高。像这样的情况导致我们编写代码为错误的数据创建 LRO,输出目的地错误,有时甚至只是完全错误的方法!如果所有这些情况都是本地运行的承诺,我们将简单地终止进程并停止执行。但是,由于我们处理的是远程执行环境,因此我们需要一种请求与此等效的方法。为此,我们可以使用自定义方法:CancelOperation.
There are a variety of reasons we might want to do this. Typos and “brain-o’s” (a mental version of a typo) happen far more often than we might like to admit. Cases like these lead to us writing code that creates an LRO for the wrong data, with the wrong destination for the output, or sometimes even just the wrong method entirely! If all of these cases were locally running promises, we’d simply kill the process and stop the execution. However, since we’re dealing with a remote execution environment, we’ll need a way of requesting the equivalent of this. To make that happen, we can use a custom method: CancelOperation.
Listing 10.12 Definition of the custom cancel method
abstract class ChatRoomApi { @post("/{id=operations/*}:cancel") ❶ CancelOperation<ResultT, MetadataT>(req: CancelOperationRequest): Operation<ResultT, MetadataT>; } interface CancelOperationRequest { id: string; }
❶ The custom method returns the fully canceled Operation resource after it’s been aborted.
就像该GetOperation方法一样,该CancelOperation方法将返回参数化Operation资源,并且该资源将其done字段设置为true(回想一下 done 是专门选择的,因为它并不意味着真正完成或操作成功)。为确保结果始终被视为已完成,此方法应阻塞,直到操作完全取消,然后才返回结果。在某些情况下,这可能需要一段时间(可能需要联系其他几个子系统);但是,等待响应仍然是合理的,就像WaitOperation10.3.3 节中的方法一样。
Just like the GetOperation method, the CancelOperation method will return the parameterized Operation resource, and this resource will have its done field set to true (recall that done was chosen specifically because it doesn’t imply true completion or success of the operation). To ensure that the result is always considered done, this method should block until the operation is fully cancelled and only then return the result. There are cases where this might take a while (perhaps there are several other subsystems that need to be contacted); however, it’s still reasonable to wait for a response, just like the WaitOperation method in section 10.3.3.
同样重要的是,如果可能,删除任何中间工作产品或作为 LRO 执行结果产生的其他输出。毕竟,取消 LRO 的目标是以一个看起来好像操作从未启动过的系统结束。在取消操作后无法清理的情况下,我们应确保用户自行执行此清理所需的所有信息都存在于操作的元数据字段中。例如,如果操作在存储系统中创建了多个文件,无论出于何种原因,在取消操作之前都无法删除这些文件,则元数据必须包含对该中间数据的引用,以便用户可以自己删除文件。
It’s also important that, if possible, any intermediate work product or other output that came about as a result of the LRO’s execution be removed. After all, the goal of canceling an LRO is to end with a system that looks as though the operation had never been initiated in the first place. In the cases where it’s not possible to clean up after the cancellation of an operation, we should ensure that all information necessary for the user to do this cleanup on their own is present in the metadata field on the operation. For example, if the operation created several files in a storage system which, for whatever reason, cannot be erased before canceling the operation, the metadata must include references to this intermediate data so that the user can erase the files themselves.
最后,虽然如果能够取消所有 LRO 类型当然会很好,但不应该严格要求整个 API 都是这种情况。原因很简单,操作的目的、复杂性以及取消或不取消的天生能力往往不同。通常取消某些操作是有意义的,而其他操作则根本不遵循该模式。我们永远不应该添加对取消对用户没有好处的 LRO 的支持。
Finally, while it would certainly be lovely if all the LRO types were able to be canceled, there should be no firm requirement that this be the case across the entire API. The reason for this is simply that operations tend to differ in their purpose, their complexity, and their innate ability to be canceled or not. Often it makes sense to cancel some operations and others simply don’t follow that pattern. And we should never add support for canceling LROs that have no benefit to the user.
有了这个,让我们看一下本质上类似于取消的不同类型的交互,只是不太持久:暂停和恢复。
With that, let’s look at a different type of interaction that’s similar in nature to cancelation, just less permanent: pause and resume.
现在我们已经打开了以有意义地改变他们的行为(例如,取消他们的工作)的方式与操作交互的大门,我们可以开始研究 LRO 的另一个重要方面:暂停正在进行的工作然后稍后恢复的能力。虽然我们可以依赖与CancelOperation我们刚刚了解的方法类似的自定义方法,但在考虑这种情况时还有更多需要考虑的地方。
Now that we’ve opened the door to interacting with operations in ways that meaningfully change their behavior (e.g., canceling their work), we can start looking at one other important aspect of LROs: the ability to pause ongoing work and then resume it later. While we can rely on a similar custom method to the CancelOperation method we just learned about, there’s a bit more to think about when considering this scenario.
最重要的是,如果一个操作被暂停了,我们当然需要一种方法来告诉它它已经被暂停了。不幸的是,资源上的done字段在Operation这里并不适用。暂停的操作没有完成,所以我们不能只是重新调整该字段的用途。这给我们留下了两个选择:向Operation界面添加一个新字段(暂停或类似的东西)或依赖元数据进行操作。
Most importantly, if an operation is paused, we certainly need a way to tell that it’s been paused. Unfortunately, the done field on the Operation resource doesn’t quite work here. A paused operation is not done, so we can’t just repurpose that field for our uses. This leaves us with two choices: add a new field to the Operation interface (paused or something similar) or rely on metadata for the operation.
尽管向Operation界面添加一个新字段非常简单,但这也带来了它自己的问题:不能保证每个操作都能暂停和恢复。在某些情况下,这可能是有道理的,但在其他情况下,这些 LRO 是孤注一掷的事务,用户无法从尝试暂停操作中获得真正的好处。例如,尝试在火箭升空后暂停发射火箭的 API。这根本没有意义!
As nice and simple as it would be to add a new field to the Operation interface, this presents its own problem: there’s no guarantee that every operation will be able to be paused and resumed. In some cases this might make sense, but in others these LROs are all-or-nothing affairs, with users obtaining no real benefits from attempting to pause an operation. For example, try pausing an API that launches a rocket after that rocket has lifted off. It just doesn’t make sense!
简而言之,这给我们留下了一个不太优雅但更好的解决方案:依赖元数据来获得这种类型的状态。这意味着有一个重要的规则:对于能够暂停和恢复的操作,元数据类型 ( MetadataT) 必须有一个名为“暂停”的布尔字段。
In short, this leaves us with the less elegant but better solution: relying on metadata for this type of status. This means that there’s an important rule: for an operation to be able to be paused and resumed, the metadata type (MetadataT) must have a Boolean field called “paused.”
Listing 10.13 Adding a paused field to the metadata interface
interface AnalyzeMessagesMetadata { chatRoom: string; paused: boolean; ❶ messagesProcessed: number; messagesCounted: number; }
❶ In order to support pausing and resuming an operation, the metadata interface must have a paused Boolean field.
暂停和恢复方法(PauseOperation和ResumeOperation) 与我们之前了解的其他以操作为中心的资源基本相同。它们接受Operation资源的标识符以及类型的参数(ResultT和MetadataT) 将在操作中返回。就像CancelOperation方法一样,这些方法应该只在操作成功暂停或恢复后返回。
The pause and resume methods (PauseOperation and ResumeOperation) are basically identical to the other operation-focused resources we learned about earlier. They accept the identifier of the Operation resource as well as the parameters for the types (ResultT and MetadataT) that will be returned on the operation. And just like the CancelOperation method, these methods should return only once the operation has been successfully paused or resumed.
Listing 10.14 Definition of the custom pause and resume methods
abstract class ChatRoomApi { @post("/{id=operations/*}:pause") PauseOperation<ResultT, MetadataT>(req: PauseOperationRequest): Operation<ResultT, MetadataT>; ❶ @post("/{id=operations/*}:resume") ResumeOperation<ResultT, MetadataT>(req: ResumeOperationRequest): Operation<ResultT, MetadataT>; ❶ } interface PauseOperationRequest { id: string; } interface ResumeOperationRequest { id: string; }
❶ The custom methods return the paused (or resumed) operation.
暂停或恢复底层工作的实际过程留作 API 的实现细节。尽管在各种不同的 API 中探索暂停和恢复工作的所有不同机制会很有趣,但这里最重要的是我们应该只在有意义且可能的情况下支持这些方法。这意味着如果我们想要支持暂停 API 方法,例如,将数据从一个地方复制到另一个地方,我们必须存储一个指向执行复制的进程的指针,并确保我们可以终止该进程,但也可能清理复制数据时造成的混乱。不过,其余的细节留给实际的 API设计师。
The actual process of pausing or resuming the underlying work is left as an implementation detail for the API. As fun as it would be to explore all the different mechanisms to pause and resume work in a variety of different APIs, the most to be said here is simply that we should only support these methods in circumstances where it both makes sense and is possible. This means if we wanted to support pausing an API method that, for example, copies data from one place to another, we must store a pointer to the process executing the copying and ensure that we can kill that process, but also potentially clean up the mess that was made while copying the data. The rest of the details, though, are left to the actual API designer.
一LRO 的一个独特之处在于,创建 API 承诺的代码与执行承诺和完成所有工作的环境是分开的。这意味着如果启动工作的代码在承诺完成之前以某种方式崩溃,我们可能会发现自己陷入了困境。除非我们保留操作 ID 以便稍后可以恢复轮询循环,否则当进程在我们的本地执行环境中终止时,指向执行我们请求的工作的操作的指针将与所有其他状态一起丢失。
One of the unique things about LROs is that the code that creates the API promise is separate from the environment that’s executing the promise and doing all the work. This means we could find ourselves in quite a predicament if the code that initiated the work was, somehow, to crash before the promise completed. Unless we persist the operation ID such that we can resume our polling loop later, that pointer to the operation performing the work we requested will be lost with all the other state when the process dies in our local execution environment.
这个场景说明了一个相当大的疏忽:我们可以根据其标识符检索有关单个Operation资源的信息,但我们无法发现正在进行的工作或检查 API 系统的状态和历史记录。幸运的是,这是一个很容易解决的问题。为了填补这个空白,我们需要做的就是为Operation我们的 API 中的资源集合定义一个标准的列表方法。
This scenario illustrates a pretty large oversight: we can retrieve the information about a single Operation resource given its identifier, but we have no way of discovering the ongoing work or inspecting the state and history of the API system. Luckily, this is an easy problem to remedy. To fill in this gap, all we need to do is define a standard list method for the collection of Operation resources in our API.
此方法的行为应与其他标准列表方法类似,但至关重要的是我们至少支持某种形式的过滤。否则,在尝试提出诸如“哪些Operation资源目前尚未完成?”之类的问题时可能会变得困难。此外,过滤还必须支持查询资源的元数据。否则,我们无法回答其他问题,例如“暂停了哪些操作?”
This method should behave like the other standard list methods, but it’s critically important that we support at least some form of filtering. Otherwise, it could become difficult when trying to ask questions such as, “What Operation resources are currently still not done?” Further, the filtering must also support querying metadata on resources. Otherwise, we have no way to answer other questions like, “What operations are paused?”
Listing 10.15 Definition of the standard list method for LROs
abstract class ChatRoomApi { @get("/operations") ListOperations<ResultT, MetadataT>(req: ➥ ListOperationsRequest): ❶ ListOperationsResponse<ResultT, MetadataT>; } interface ListOperationsRequest { filter: string; ❷ } interface ListOperationsResponse<ResultT, MetadataT> { results: Operation<ResultT, MetadataT>[]; }
❶ This follows the standard list method but allows parameterization.
❷ Later we’ll look at how to apply pagination.
这个简单的 API 方法使我们能够回答我们可能遇到的关于操作的所有问题,特别是在我们碰巧没有提前获得标识符的情况下。请注意,该操作确实接受结果类型和元数据类型的参数,但这不应该禁止请求所有操作的能力,无论涉及的类型如何。我们可以使用 TypeScriptany关键字来做到这一点; 但是,将由调用者检查结果以确定特定类型(在大多数情况下,结果的标识符将包括资源类型)。
This simple API method allows us to answer all the questions we might have about operations, specifically in the case where we don’t happen to have the identifiers ahead of time. Note that the operation does accept parameters for the types of the result and metadata types, but this shouldn’t prohibit the ability to ask for all operations, regardless of the types involved. We might do this using the TypeScript any keyword; however, it will be up to the caller to inspect the result to determine the specific type (in most cases, the identifier of the result will include the resource type).
可以想象,过滤所有这些资源是非常强大的,但是一旦完成这些操作,我们就必须考虑将这些记录保留多久。在下一节中,我们将探讨这些 LRO 的持久性标准记录。
As you can imagine, filtering through all these resources is quite powerful, but once these operations are done, we have to consider how long to keep these records around. In the next section, we’ll explore the persistence criteria for these LRO records.
尽管确实,这些LRO是资源(具体来说是resources Operation),和我们自己定义的resources有点不一样。首先,它们不是明确创建的,而是通过系统中的其他行为而存在的(例如,请求对数据进行分析可能会导致Operation创建资源来跟踪正在完成的工作)。这种隐式创建与我们通常使用标准创建方法使资源存在的典型方式根本不同。
While it’s true that these LROs are resources (Operation resources, to be specific), they are a bit different from the resources we’ve defined ourselves. First, they aren’t explicitly created but instead come into existence by virtue of other behavior in the system (e.g., requesting an analysis of the data might lead to an Operation resource being created to track the work being done). This implicit creation is fundamentally different from our typical way of bringing resources into existence, usually with a standard create method.
但更重要的是,这些资源很快就会从至关重要的地方变成旧新闻。例如,作为标准方法(例如,CreateChatRoom) 在处理时非常重要,但一旦完成就变得毫无用处。这样做的原因是结果本身是操作的永久记录输出,因此跟踪该资源创建的过程在那之后就不是特别有价值了。
More importantly, though, these resources go from a place of being critically important to old news very quickly. For example, any LRO that is the result of a standard method (e.g., CreateChatRoom) is very important when it’s processing but becomes practically useless once it’s finished. The reason for this is that the result itself is the permanent recorded output of the operation, so the process that tracked the creation of that resource isn’t particularly valuable after that point.
这意味着我们必须解决一个令人困惑的问题:我们是否像对待任何其他资源一样对待 LRO 并永远保留它们?或者它们的持久性策略是否与其他更传统的资源略有不同?如果他们确实有不同的持久性政策,那么正确的政策是什么?
This means we have to address a confusing issue: do we treat LROs like any other resource and persist them forever? Or do they have a slightly different persistence policy than other, more traditional resources? If they do have a different policy for persistence, what is the right policy?
事实证明,有很多不同的选择,每个都有自己的优点和缺点。最容易管理和最快实施的是Operation像对待任何其他资源一样对待资源并永久保留它们。通常,大多数 API 都应该依赖此方法,除非它会给系统带来不应有的负担(通常是当您Operation每天创建数百万个资源时)。
It turns out there are quite a few different options, each with their own benefits and drawbacks. The easiest to manage and quickest to implement is to treat Operation resources like any other and keep them forever. In general, most APIs should rely on this method unless it would place undue burden on the system (typically when you have many millions of Operation resources being created on a daily basis).
如果 API 的交互模式使这种永久存储方法特别浪费,那么下一个最佳选择是依赖滚动窗口,根据字段Operation中定义的完成时间戳(而不是创建时间戳)清除资源expireTime在Operation资源上设置。简而言之,如果资源存在时间超过 30 天,则应将其永久删除。
If the interaction patterns of an API make this perpetual storage method particularly wasteful, then the next best option is to rely on a rolling window, with Operation resources being purged based on their completion timestamp (not their creation timestamp), defined in an expireTime field set on the Operation resource. In short, if a resource has been around longer than, say, 30 days, it should be deleted permanently.
Listing 10.16 Adding an expiration field to the Operation interface
interface Operation<ResultT, MetadataT> { id: string; done: boolean; expireTime: Date; ❶ result?: ResultT | OperationError; metadata?: MetadataT; }
❶ We can use an expiration time to show when the resource will be deleted from the system.
还有许多更复杂、功能更丰富的选项(例如,当它们绑定的资源被删除时删除操作,或者在一定时间后归档操作,然后在它们被归档另一个时间窗口后删除它们) ), 但这些应该避免。最终,由于混淆和复杂的清除算法,它们会造成更多困难。相反,依靠一个简单的过期时间很容易让每个人都遵循并且对最终结果毫不含糊。
There are many more complex and feature-rich options (such as deleting operations when the resources they’re tied to are deleted, or archiving operations after a certain amount of time and then deleting them after they’ve been archived for another window of time), but these should be avoided. Ultimately, they cause more difficulty due to confusion and complicated purging algorithms. Instead, relying on a simple expiration time is easy for everyone to follow and unambiguous about the end results.
最重要的是要记住,资源的过期时间不应取决于操作的底层结果类型。换句话说,代表创建资源工作的操作应该在与代表分析数据工作的操作相同的时间后过期。如果我们对不同类型的操作使用不同的过期时间,可能会导致更多的混乱,结果会以不可预测的方式消失方式。
The most important thing to remember is that expiration times of resources should not depend on the underlying result types of the operation. In other words, an operation that represented work to create a resource should expire after the same amount of time as an operation that represented work to analyze data. If we use different expiration times for different types of operations, it can lead to more confusion, with results disappearing in what appears to be an unpredictable manner.
推杆一切都在一起,清单 10.17 显示了所有这些相关的各种方法和接口的完整 API 定义到LRO。
Putting everything together, listing 10.17 shows the full API definition for all of these various methods and interfaces related to LROs.
Listing 10.17 Final API definition
abstract class ChatRoomApi { @post("/{parent=chatRooms/*}/messages:analyze") AnalyzeMessages(req: AnalyzeMessagesRequest): Operation<MessageAnalysis, AnalyzeMessagesMetadata>; @get("/{id=operations/*}") GetOperation<ResultT, MetadataT>(req: GetOperationRequest): Operation<ResultT, MetadataT>; @get("/operations") ListOperations<ResultT, MetadataT>(req: ListOperationsRequest): ListOperationsResponse<ResultT, MetadataT>; @get("/{id=operations/*}:wait") WaitOperation<ResultT, MetadataT>(req: WaitOperationRequest): Operation<ResultT, MetadataT>; @post("/{id=operations/*}:cancel") CancelOperation<ResultT, MetadataT>(req: CancelOperationRequest): Operation<ResultT, MetadataT>; @post("/{id=operations/*}:pause") PauseOperation<ResultT, MetadataT>(req: PauseOperationRequest): Operation<ResultT, MetadataT>; @post("/{id=operations/*}:resume") ResumeOperation<ResultT, MetadataT>(req: ResumeOperationRequest): Operation<ResultT, MetadataT>; } interface Operation<ResultT, MetadataT> { id: string; done: boolean; expireTime: Date; result?: ResultT | OperationError; metadata?: MetadataT; } interface OperationError { code: string; message: string; details?: any; } interface GetOperationRequest { id: string; } interface ListOperationsRequest { filter: string; } interface ListOperationsResponse<ResultT, MetadataT> { results: Operation<ResultT, MetadataT>[]; } interface WaitOperationRequest { id: string; } interface CancelOperationRequest { id: string; } interface PauseOperationRequest { id: string; } interface ResumeOperationRequest { id: string; } interface AnalyzeMessagesRequest { parent: string; } interface MessageAnalysis { chatRoom: string; messageCount: number; participantCount: number; userGradeLevels: map<string, number>; } interface AnalyzeMessagesMetadata { chatRoom: string; paused: boolean; messagesProcessed: number; messagesCounted: number; }
清楚地有很多方法可以处理可能需要一段时间的 API 调用。例如,最简单的方法之一也恰好是最容易为客户管理的方法:只需让请求根据需要进行。这些更简单的选项的权衡是它们不太适合分布式架构(例如,一个微服务启动工作,另一个微服务监控其进度)。
Clearly there are many ways to handle API calls that might take a while. For example, one of the simplest also happens to be the easiest to manage for clients: just make the request take as long as it needs. The trade-off with these more simple options is that they don’t lend themselves very well to distributed architecture (e.g., one microservice to initiate the work, another to monitor its progress).
另一个权衡是,实际监视挂起直到完成的 API 调用的进度可能会变得有点复杂。虽然 API 可以随时间推送数据,但如果连接因任何原因中断(可能是进程崩溃、网络连接中断等),我们将失去恢复监控该进度的能力。此外,如果我们决定实施一些措施以在发生此类网络故障后恢复监控进度,那么我们构建的内容很有可能开始类似于 LRO 的概念。
Another trade-off is that it can become a bit more complicated to actually monitor the progress of an API call that hangs until completion. While the API can push data over time, if the connection is broken for any reason (perhaps a process crashes, a network connection is interrupted, etc.), we lose our ability to resume monitoring that progress. And further, if we decide to implement something to resume monitoring progress after a network failure like that, there’s a good chance that what we build will start to resemble the concept of LROs.
LRO 起初也可能有点难以理解。它们是复杂的、通用的、参数化的资源,它们不是明确创建的,而是通过请求其他工作而存在的。这无疑打破了我们迄今为止所了解的资源创建模式。此外,当谈到“可重新运行的作业”和 LRO 的概念时,人们常常会混淆,我们将在第 11 章中更详细地讨论。
LROs can also be a bit tricky to understand at first. They are complicated, generic, parameterized resources that aren’t explicitly created but instead come into existence by virtue of other work being requested. This certainly breaks the mold of resource creation that we’ve learned about so far. Further, there is often confusion when it comes to the idea of “rerunnable jobs” and LROs, which we’ll discuss in more detail in chapter 11.
简而言之,虽然这种设计模式一开始可能难以掌握,但随着时间的推移,API 承诺的概念很容易与 API 用户产生共鸣。而且模式本身几乎没有施加任何实际限制——毕竟,如果用户需要并且没有什么可以阻止我们保持各种 API 方法的同步版本预计。
In short, while this design pattern can be difficult to grasp at first, the concept of API promises can become easily relatable to API users over time. And the pattern itself imposes almost no practical limitations—after all, there’s nothing stopping us from keeping a synchronous version of the various API methods if that’s what users need and expect.
Why should operation resources be kept as top-level resources rather than nested under the resources they operate on?
If a user is waiting on an operation to resolve and the operation is aborted via the custom cancel method, what should the result be?
When tracking progress of an LRO, does it make sense to use a single field to store the percentage complete? Why or why not?
LROs are promises or futures for web APIs that act as a tool for tracking work being done in the background by an API service.
LROs are parameterized interfaces, returning a specific result type (e.g., the resource resulting from the operation) and a metadata type for storing progress information about the operation itself.
LROs resolve after a certain amount of time to a result or an error and users discover this either by polling for status updates periodically or being notified of a result while waiting.
LROs may be paused, resumed, or canceled at the discretion of the API and rely on custom methods to do so.
LROs should be persisted to storage but should generally expire after some standard period of time, such as 30 days.
在许多情况下,API 需要公开一些重复运行的可定制功能;但是,我们并不总是希望每次需要运行时都需要提供该功能的所有详细信息。此外,我们可能希望能够按照由 API 服务器拥有和维护的计划执行这块可配置工作,而不是由客户端调用。在此模式中,我们探索了一种标准,用于定义可配置和可重新运行的特定工作单元,可能由在 API 中具有不同访问级别的用户来定义。我们还提供了一种模式来存储每个执行的输出。
There are many cases where an API needs to expose some customizable functionality that runs repeatedly; however, we don’t always want to be required to provide all the details for that functionality each time it needs to be run. Further, we may want the ability to execute this chunk of configurable work on a schedule that is owned and maintained by the API server rather than invoked by the client. In this pattern, we explore a standard for defining specific units of work that are configurable and rerunnable, potentially by users with different levels of access in the API. We also provide a pattern for storing the output of each of these executions.
正如我们在第 10 章中了解到的,有时我们会遇到 API 中的方法不能或不应该立即返回。具有处理异步执行的能力显然非常有价值;但是,它仍然需要客户端通过调用 API 方法在某处触发调用。这适用于仅打算按需运行的方法,但有三种情况值得考虑,仅通过返回 LRO 的方法并不能完全解决。
As we learned in chapter 10, sometimes we’ll come across methods in an API that cannot, or should not, return immediately. Having the ability to handle asynchronous execution is clearly very valuable; however, it still requires that a client trigger the invocation somewhere by calling the API method. This works fine for methods that are only ever intended to run on demand, but there are three scenarios worth considering that aren’t quite solved by simply having a method that returns an LRO.
首先,对于异步运行的典型自定义方法,每次调用都需要调用者提供该方法所需的所有相关配置。当方法不需要太多配置时,这可能简单明了,但随着配置参数的数量随着时间的推移而增加,每次调用都会提供相当多的信息。这使得源代码控制变得更加重要,因为查找存储在 Jim 膝上型计算机某处的配置参数中的错误可能会带来各种挑战。能够将这些配置参数存储在 API 本身中可能是一项非常有用的功能。
First, with a typical custom method that runs asynchronously, each invocation requires the caller to provide all the relevant configuration necessary for the method. This might be simple and straightforward when the method doesn’t require much configuration, but as the number of configuration parameters increases over time there will be quite a lot of information provided at each invocation. This makes source control all the more important, because chasing down a mistake in the configuration parameters that is stored somewhere on Jim’s laptop can raise a variety of challenges. Having the ability to store these configuration parameters in the API itself might be a very useful bit of functionality.
其次,对于调用方法的按需模型,我们将两组权限混为一谈:运行方法的能力和选择调用该方法的参数的能力。通常这没什么大不了的,但是,同样,随着配置参数的复杂性随着时间的推移变得越来越复杂,我们可能希望将职责分为两组:一组可以配置方法的调用方式,另一组可以配置方法的调用方式。可以调用方法。当 API 用户开始将开发人员和操作人员区分为具有不同职责和权限的不同团队时,这一点变得尤为重要。正如您可能猜到的那样,很难强制执行诸如“用户 A 只能使用这些确切的配置参数调用此方法”之类的权限。
Second, with an on-demand model of invoking a method, we conflate two sets of permissions: the ability to run the method and the ability to choose the parameters for the invocation of that method. Often this is not a big deal, but, again, as the complexity of configuration parameters grows more and more over time, we may want to split the responsibilities up into two groups: those that can configure how the method should be called and those that can call the method. This becomes particularly important when API users begin to distinguish between developers and operations as distinct teams with distinct responsibilities and permissions. As you might guess, it’s tough to enforce permissions that say things like “User A can only call this method with these exact configuration parameters.”
最后,虽然按需运行方法已经让我们走了很长一段路,但很有可能最终我们希望能够按照某种重复性计划自动调用方法。即使可以设置客户端设备以按特定时间表进行这些不同的 API 调用(例如,使用基于中定义运行脚本的服务器crontab),这会引入一个新的、可能有故障的子系统来负责成功这项工作。相反,如果我们可以在 API 本身中配置调度,这样服务就可以在没有外部参与的情况下为我们调用方法,事情就会简单得多。
Finally, while running methods on demand has gotten us quite a long way, there is a good chance that eventually we’ll want to be able to automatically invoke methods on some sort of recurring schedule. And even though it’s certainly possible to set up a client device to make these various API calls on a specific schedule (e.g., with a server that runs a script based on definitions in crontab), that introduces a new, potentially faulty subsystem responsible for the success of this work. Instead, it’d be much simpler if we could configure the scheduling in the API itself so that the service can invoke the method for us without external participation.
我们或许应该为可配置作业定义一个标准,该作业可以存储各种配置参数并根据需要(可能按计划)重新运行。
We should probably define a standard for configurable jobs that can store the various configuration parameters and be rerun as needed (potentially on a schedule).
为了解决这三种情况,我们可以依赖一个简单但功能强大的概念:作业。作业是一种特殊类型的资源,它将 API 方法的按需版本分成两部分。当我们调用一个典型的 API 方法时,我们会提供要完成的工作的配置,然后该方法会立即使用该配置执行工作。有了工作,这些就变成了两个不同的功能。首先,我们创建一个作业,其中包含应该完成的工作的配置。Job然后,稍后,我们可以通过在资源上调用自定义方法来执行实际工作,称为“奔跑”。图 11.1 显示了 API 调用的示例序列,概述了我们如何使用作业。
To address these three scenarios, we can rely on a simple but powerful concept: a job. A job is a special type of resource that breaks the on-demand version of an API method into two pieces. When we call a typical API method, we provide the configuration for the work to be done and the method executes the work using that configuration right then and there. With a job, these become two distinct bits of functionality. First, we create a job with the configuration for the work that should be done. Then, later, we can perform the actual work by calling a custom method on the Job resource, called “run.” An example sequence of API calls outlining how we might use jobs is shown in figure 11.1.
Figure 11.1 Interaction with a Job resource
通过将工作分成两个独立的部分,我们为解决第 11.1 节中描述的所有三个用例奠定了基础。首先,无需担心配置参数数量的增加,因为我们只需要管理它们一次:当我们创建作业资源时。从那里开始,我们调用不带任何参数的 run 方法。其次,由于这是两个不同的 API 方法,我们可以控制谁可以访问哪些。可以很简单地说,特定用户可能有权执行预配置的作业,但不允许创建或修改作业。最后,这提供了一种简单的方法来在未来将调度作为 API 的一部分进行处理。而不是接受大量参数的复杂调度系统,
By splitting the work into two separate components, we lay the groundwork for solving all three of the use cases described in section 11.1. First, there’s no need to worry as the number of configuration parameters grows because we only need to manage them once: when we create the job resource. From there on out, we call the run method without any parameters. Second, since these are two distinct API methods, we can control who has access to which ones. It’s quite simple to say that a specific user may have access to execute a preconfigured job but is not allowed to create or modify jobs. Finally, this provides a simple way to handle scheduling as part of the API in the future. Rather than a complicated scheduling system that accepts lots of parameters, we can simply request that a single API method is called with no parameters at all on a specific schedule.
描述我们想从工作中得到什么要容易得多,而要深入了解这些神奇工作如何运作的细节则要复杂得多。在下一节中,我们将深入探讨作业的细节以及使它们发挥作用所涉及的各种标准和自定义方法故意的。
It’s much easier to describe what we want out of jobs and much more complicated to get into the details of how these magical jobs work. In the next section, we’ll dive into the details of jobs and the various standard and custom methods involved in making them function as intended.
在为了为这些可重新运行的作业提供支持,我们需要实现两个关键组件:Job资源定义(具有所有关联的标准方法)和实际执行作业预期工作的自定义运行方法。让我们从定义Job资源的外观开始。
In order to provide support for these rerunnable jobs, we’ll need to implement the two key components: a Job resource definition (with all the associated standard methods) and a custom run method that actually executes the work expected of the job. Let’s begin by defining what a Job resource looks like.
职位基本上与我们在 API 中处理的任何其他资源一样。就像其他资源一样,唯一真正需要的字段是唯一标识符,正如我们在第 6 章中了解到的那样,理想情况下应该由 API 服务选择。然而,这种类型的资源的基本目标是为一些东西存储一堆配置参数,否则这些东西最终会作为返回 LRO 的方法的请求消息。
Jobs are fundamentally like any other resource we deal with in an API. And just like other resources, the only field that’s truly required is the unique identifier, which, as we learned in chapter 6, should ideally be chosen by the API service. However, the fundamental goal of this type of resource is to store a bunch of configuration parameters for something that otherwise would have ended up as the request message for a method that returns an LRO.
以我们聊天室 API 中备份方法的想法为例。支持此功能的按需方式是定义触发ChatRoom资源备份的自定义方法及其所有消息。与如何创建备份相关的所有参数(例如,数据应该在哪里结束以及数据应该如何压缩或加密)都将包含在请求消息中。
Take the idea of a backup method in our chat room API as an example. The on-demand way of supporting this functionality would be to define a custom method that triggers a backup of a ChatRoom resource and all its messages. All of the parameters relevant to how the backup should be created (e.g., where the data should end up and how it should be compressed or encrypted) would be in the request message.
Listing 11.1 On-demand custom backup method
abstract class ChatRoomApi { @post("/{id=chatRooms/*}:backup") BackupChatRoom(req: BackupChatRoomRequest): ➥ Operation<BackupChatRoomResponse, BackupChatRoomMetadata>; ❶ } interface BackupChatRoomRequest { id: string; ❷ destination: string; ❸ compressionFormat: string; encryptionKey: string; } interface BackupChatRoomResponse { destination: string; ❹ } interface BackupChatRoomMetadata { messagesCounted: number; messagesProcessed: number; bytesWritten: number; }
❶作为按需异步方法,我们返回一个标准响应接口结果类型的LRO。
❶ As an on-demand asynchronous method, we return an LRO with a standard response interface result type.
❷ This identifier refers to the ChatRoom resource being backed up.
❸ These represent the different configuration parameters for a backup operation.
❹ The response simply points to where the backed-up data lives in an external storage system.
正如我们将在第 23 章中了解到的那样,这种方法肯定会起作用并以生成的备份位置作为响应。然而,正如我们在 11.1 节中讨论的那样,这可能会带来各种问题。也许我们希望管理员能够触发备份操作,但我们不希望他们选择加密密钥或在时间之外以任何重要方式配置操作细节。或者,也许我们想要一个负责触发备份的调度服务,但我们希望能够单独配置重复行为,这意味着调度服务应该只触发备份并将所有配置方面留给其他人。
As we will learn in chapter 23, this method would certainly work and respond with the resulting backup location. However, as we discussed in section 11.1, this can present a variety of problems. Perhaps we want administrators to be able to trigger a backup operation, but we don’t want them to choose the encryption key or configure the details of the operation in any significant way outside of the timing. Alternatively, perhaps we want to have a scheduling service responsible for triggering the backup, but we want to be able to configure the recurring behavior separately, meaning the scheduling service should only ever trigger the backup and leave all configuration aspects to someone else.
我们可以很容易地将这个按需 API 方法转换为可重新运行的作业资源:只需移动请求消息中提供的任何内容,并将这些字段视为BackupChatRoomJob资源上的可配置字段反而。
We can turn this on-demand API method into a rerunnable job resource quite easily: simply move whatever would have been provided in the request message and treat those fields as configurable fields on a BackupChatRoomJob resource instead.
Listing 11.2 A Job resource for backing up chat data
interface BackupChatRoomJob { id: string; ❶ chatRoom: string; ❶ destination: string; ❷ compressionFormat: string; }
❶请注意,这个新资源有一个标识符,我们将请求的标识符字段重命名为 chatRoom,作为对将要备份的资源的引用。
❶ Note that this new resource has an identifier, and we rename the request’s identifier field to chatRoom, acting as a reference to the resource that will be backed up.
❷请求消息中的所有其他字段也应该存在于 BackupChatRoomJob 资源中。
❷ All the other fields from the request message should also live in the BackupChatRoomJob resource.
由于我们现在处理的是资源而不是返回 LRO 的按需 API 方法,因此我们需要支持标准方法的典型集合。我们将在最终的 API 定义中详细介绍这些内容(请参阅第 11.3.4 节),但表 11.1 总结了我们应该实现的标准方法以及这样做的理由。
Since we’re now dealing with a resource rather than an on-demand API method that returns an LRO, we need to support the typical collection of standard methods. We’ll cover these in detail in the final API definition (see section 11.3.4), but table 11.1 summarizes the standard methods we should implement and the rationale for doing so.
Table 11.1 Standard methods and the rationale for each
正如我们在第 7 章中看到的,在某些情况下,某些方法可能会从 API 服务中省略。在这种情况下,这些BackupChatRoomJob资源有可能被认为是不可变的。换句话说,创建这些资源然后再不对其进行修改的情况并不少见。相反,用户可能会删除现有资源并创建新资源以避免任何潜在的并发问题(例如,如果作业在我们更新时正在运行)。在这种情况下,省略标准更新方法当然是可以接受的。
As we saw in chapter 7, there are scenarios where certain methods might be omitted from an API service. In this case, there’s the possibility that these BackupChatRoomJob resources can be considered immutable. In other words, it’s not unusual for these resources to be created and then never modified. Instead, users might delete an existing resource and create a new one to avoid any potential issues with concurrency (for example, if the job is running while we’re updating it). In cases like these, it’s certainly acceptable to omit the standard update method.
此外,虽然按需方法返回一个长时间运行的操作(因为它可能有大量工作要执行),但这些标准方法应该是即时和同步的。这是因为我们已经从这些方法中取出实际工作并让它们专注于配置。在下一节中,我们将看看如何实际触发作业资源本身来完成我们使用这些标准配置的工作方法。
Additionally, while the on-demand method returned a long-running operation (as it had potentially a large amount of work to perform), these standard methods should be instantaneous and synchronous. This is because we’ve taken out the actual work from these methods and left them to focus exclusively on the configuration. In the next section, we’ll look at how to actually trigger the job resource itself to do the work we’re configuring with these standard methods.
假设我们已经创建并预配置了一个Job资源,下一个难题是实际执行作业,以便执行我们配置的工作。为此,每个Job资源都应该有一个自定义运行方法来负责执行基础工作。这个运行方法不应该接受任何其他参数(毕竟,我们要确保所有相关配置作为Job资源的一部分持久化)并且应该返回一个 LRO,如第 10 章中所见,最终解析为类似响应消息的内容我们的按需定制方法。
Assuming we’ve created and preconfigured a Job resource, the next piece of the puzzle is to actually execute the job so that the work that we’ve configured is performed. To do this, each Job resource should have a custom run method responsible for doing the underlying work. This run method should not accept any other parameters (after all, we want to ensure all relevant configuration is persisted as part of the Job resource) and should return an LRO, as seen in chapter 10, that ultimately resolves to something like the response message of our on-demand custom method.
Listing 11.3 Custom run method for rerunnable backup jobs
abstract class ChatRoomApi { @post("/{id=backupChatRoomJobs/*}:run") ❶ RunBackupChatRoomJob(req: RunBackupChatRoomJobRequest): Operation<RunBackupChatRoomJobResponse, RunBackupChatRoomJobMetadata>; ❷ } interface RunBackupChatRoomJobRequest { id: string; ❸ } interface RunBackupChatRoomJobResponse { ❹ destination: string; } interface RunBackupChatRoomJobMetadata { ❹ messagesCounted: number; messagesProcessed: number; bytesWritten: number; }
❶ The custom run method follows the standards discussed in chapter 9.
❷ Since there’s quite a bit of work to do, we can return an LRO right away.
❸ It’s critical that no extra configuration is passed in at runtime.
❹ These are renamed but use the same fields as the on-demand custom method.
如您所见,虽然大多数字段保持不变,但一些消息名称略有更改以适应这种新结构。例如BackupChatRoomResponse,我们有一个RunBackupChatRoomJobResponse. 同样重要的是要指出RunBackupChatRoomJobRequest只接受一个输入:Job要运行的资源的标识符。与作业本身的执行相关的任何其他信息都应存储在资源中,永远不要在执行时提供。
As you can see, while most of the fields remain the same, several of the message names are slightly altered to fit with this new structure. For example, rather than a BackupChatRoomResponse message, we’d have a RunBackupChatRoomJobResponse. It’s also critically important to point out that the RunBackupChatRoomJobRequest accepts only a single input: the identifier of the Job resource to run. Any other information relevant to the execution of the job itself should be stored on the resource, never provided at execution time.
同样重要的是要注意,虽然此示例以响应的形式提供明确的输出,说明备份数据的目的地(可能是s3:///backup-2020-01-01.bz2),但许多其他可重新运行的作业可能不会遵循相同的模式。有些人可能会通过修改其他预先存在的资源(例如 a BatchArchiveChatRoomsJob)或创建新资源(例如ImportChatRoomsJob我们将在第 23 章中看到的 an )来执行他们的工作。其他人可能仍然拥有完全短暂的输出,产生某种并非设计为永久存储的分析。不幸的是,这些情况可能会因为一个重要原因而出现问题:LRO 资源的保留并非一成不变(请参阅第 13.3.10 节)。那么我们能做什么做?
It’s also important to note that while this example has a clear output in the form of a response that states the destination of the backup data (perhaps s3:///backup-2020-01-01.bz2), many other rerunnable jobs might not follow this same pattern. Some might perform their work by modifying other preexisting resources (e.g., a BatchArchiveChatRoomsJob) or creating new resources (e.g., an ImportChatRoomsJob which we’ll see in chapter 23). Others still may have output that is completely ephemeral, producing analysis of some sort that wasn’t designed to be stored permanently. Unfortunately, these cases may present a problem for an important reason: retention of LRO resources is not set in stone (see section 13.3.10). So what can we do?
在在某些情况下,可重新运行的作业的结果不会对 API 产生任何持久影响。这意味着调用作业的自定义运行方法不会将任何实际数据写入 API,除了创建 LRO 资源;不更新现有资源,不创建新资源,不向外部数据存储系统写入数据,什么都没有!
In some cases, the results of a rerunnable job won’t have any persistent effect on the API. This means that calling the job’s custom run method doesn’t write any actual data to the API except for creating the LRO resource; no updates to existing resources, no creation of new resources, no written data to external data storage systems, nothing!
虽然这本身并没有什么错,但它导致我们产生了一种重要的依赖性。突然之间,这项工作所完成的工作的持久性取决于 API 的 LRO 持久性策略。而且,正如我们在第 13.3.10 节中提到的,该政策非常广泛。虽然它鼓励永恒的持久性和永不删除任何记录,但没有什么可以阻止 API 使Operation资源在一段时间后过期和消失。
While there’s nothing inherently wrong with this, it leads us to an important dependency. Suddenly, the persistent nature of the work being done by this job is at the mercy of whatever the API’s policy on persistence for LROs happens to be. And, as we noted in section 13.3.10, this policy is pretty broad. While it encourages eternal persistence and never deleting any records, there’s nothing stopping APIs from having Operation resources expire and disappear after a certain period of time.
由于我们可能希望这些作业的输出持久性策略可能不同于系统中所有 LRO 的持久性策略,因此我们有两个选择。首先是改变我们保留 LRO 资源多长时间的政策,以适应我们对这种特定类型工作的期望。第二个是让不同的操作在不同的时间量内过期(例如,大多数操作在 30 天后过期,但特定类型作业的操作会永远持续)。虽然第一个是合理的,但可能过度,第二个会导致不一致和不可预测性,最终意味着我们的 API 将更加混乱和不可用。那么我们还能做什么呢?
Since we may want to have a durability policy of the output from these jobs that could be different from the durability policy of all the LROs in the system, we’re left with two choices. The first is to change the policy of how long we keep LRO resources around to fit with the expectations we have with this specific type of job. The second is to have different operations expire in different amounts of time (e.g., most operations expire in 30 days, but operations for a specific type of job last forever). While the first is reasonable, though potentially excessive, the second leads to inconsistency and unpredictability, ultimately meaning our API will be more confusing and less usable. So what else can we do?
第三种选择是依赖资源的子集合,其行为有点像Operation资源,称为executions。每个Execution资源将代表自定义运行方法的输出,可以在这些不同的作业资源上调用,并且与 LRO 不同,它们可以有自己的(通常是永久性的)持久性策略。
A third option is to rely on a subcollection of resources that acts a bit like Operation resources, called executions. Each Execution resource will represent the output of the custom run method that can be invoked on these different job resources, and unlike the LROs, they can have their own (usually perpetual) persistence policy.
例如,让我们考虑一种分析方法,该方法查看聊天室并得出句子复杂性评级、消息的整体情绪以及聊天中是否存在辱骂性语言的评级。由于我们希望定期运行此功能,因此我们希望此功能可作为可重新运行的作业实例使用,但显然此分析作业的结果不是现有资源或外部数据源。相反,我们必须定义一个Execution资源来表示单次运行分析作业的输出。
For example, let’s consider an analysis method that looks at a chat room and comes up with ratings for sentence complexity, overall sentiment of the messages, and a rating for whether there is abusive language present in the chat. Since we want to run this on a recurring basis, we want this functionality available as a rerunnable job instance, but obviously the results of this analysis job are not existing resources or external data sources. Instead, we’ll have to define an Execution resource to represent the output of a single run of the analysis job.
Listing 11.4 An example Execution resource for analyzing chat data
interface AnalyzeChatRoomJobExecution { id: string; ❶ job: AnalyzeChatRoomJob; ❷ sentenceComplexity: number; ❸ sentiment: number; abuseScore: number; }
❶ Since this is a proper resource, it requires its own unique identifier.
❷为了确保我们知道生成此执行的配置是什么,我们存储了 AnalyzeChatRoomJob 资源的快照。
❷ To ensure we know what configuration went into producing this execution, we store a snapshot of the AnalyzeChatRoomJob resource.
❸ All the resulting analysis information is stored in the Execution resource.
现在,对自定义 run 方法的调用仍然会创建一个Operation资源来跟踪正在执行的作业的工作,但是一旦完成,而不是返回一个短暂的输出接口供立即使用(例如,带有sentiment字段的东西),API将创建AnalyzeChatRoomJobExecution资源,LRO 将返回对该资源的引用。这有点像异步标准创建方法最终返回 LRO 解析时创建的资源的方式。
Now, a call to the custom run method would still create an Operation resource to track the work of the job being performed, but when once complete, rather than returning an ephemeral output interface for immediate consumption (e.g., something with the sentiment field), the API would create an AnalyzeChatRoomJobExecution resource and the LRO would return a reference to that resource. This is a bit like the way an asynchronous standard create method would ultimately return the resource that was created when the LRO resolves.
Listing 11.5 Definition of the custom run method with Execution resources.
abstract class ChatRoomApi { @post("/analyzeChatRoomJobs") ❶ CreateAnalyzeChatRoomJob(req: CreateAnalyzeChatRoomJob): AnalyzeChatRoomJob; @post("/{id=analyzeChatRoomJobs/*}:run") ❷ RunAnalyzeChatRoomJob(req: RunAnalyzeChatRoomJobRequest): Operation<AnalyzeChatRoomJobExecution, RunAnalyzeChatRoomJobMetadata>; }
❶ A standard create method for the analysis jobs
❷注意我们返回一个 Operation 资源,其结果类型为 Execution 资源。
❷ Notice we return an Operation resource with an Execution resource result type.
如您所见,自定义运行方法与我们之前的备份示例几乎相同,但结果是Execution资源而不是临时响应接口。为了更清楚地看到这一点,图 11.2 显示了运行该作业的过程以及 API 中应该发生的事情。
As you can see, the custom run method is almost identical to our previous backup example, but the result is an Execution resource instead of the ephemeral response interface. To see this more clearly, figure 11.2 shows the process of running the job and what should be happening under the hood in the API.
Figure 11.2 Interaction with a Job resource with Execution results
最后,由于这些执行是真正的资源,我们必须实现一些标准方法才能使它们有用。在这种情况下,这些资源是不可变的,所以我们不需要实现标准的更新方法;但是,它们也仅由内部流程创建(从不由最终用户创建)。因此,我们也不应该实现标准的创建方法。表 11.2 显示了我们执行所需的标准方法及其背后的基本原理的摘要。
Finally, since these executions are true resources, we have to implement a few of the standard methods in order to make them useful. In this case, these resources are immutable, so we don’t need to implement the standard update method; however, they are also created by internal processes only (never by end users). As a result, we also shouldn’t implement the standard create method. Table 11.2 shows a summary of the standard methods we need for executions as well as the rationale behind them.
Table 11.2 Summary of standard methods and rationale for each
最后一个问题是关于资源布局:这些执行应该位于资源层次结构中的什么位置?与处理来自所有 API 的资源的 LRO 不同,执行被设计并限定为单一作业类型。此外,由于我们想问的最常见问题之一是“这个特定作业发生了什么执行?”,因此将执行范围限定在该作业的上下文中是很有意义的。换句话说,我们在执行中寻找的行为表明这些资源的最佳位置是作业资源的子资源他们自己。
The final question is about resource layout: where in the resource hierarchy should these executions live? Unlike the LROs that deal with resources from all over the API, executions are designed and scoped to a single job type. Further, since one of the most common questions we want to ask is “What executions have happened for this specific job?”, it makes quite a bit of sense to scope the executions inside the context of that job. In other words, the behavior we’re looking for out of executions indicates that the best place for these resources is as children of the job resources themselves.
为了最终的 API 定义,清单 11.6 显示了用于分析聊天室消息的所有 API 方法和接口。在这种情况下,输出依赖于执行资源来存储结果永久。
For the final API definition, listing 11.6 shows all the API methods and interfaces for analyzing a chat room’s messages. In this instance, the output relies on an execution resource to store the results permanently.
Listing 11.6 Final API definition
abstract class ChatRoomApi { @post("/analyzeChatRoomJobs") CreateAnalyzeChatRoomJob(req: CreateAnalyzeChatRoomJobRequest): AnalyzeChatRoomJob; @get("/analyzeChatRoomJobs") ListAnalyzeChatRoomJobs(req: ListAnalyzeChatRoomJobsRequest): ListAnalyzeChatRoomJobsResponse; @get("/{id=analyzeChatRoomJobs/*}") GetAnalyzeChatRoomJob(req: GetAnalyzeChatRoomJobRequest): AnalyzeChatRoomJob; @patch("/{resource.id=analyzeChatRoomJobs/*}") UpdateAnalyzeChatRoomJob(req: UpdateAnalyzeChatRoomJobRequest): AnalyzeChatRoomJob; @post("/{id=analyzeChatRoomJobs/*}:run") RunAnalyzeChatRoomJob(req: RunAnalyzeChatRoomJobRequest): Operation<AnalyzeChatRoomJobExecution, RunAnalyzeChatRoomJobMetadata>; @get("/{parent=analyzeChatRoomJobs/*}/executions") ListAnalyzeChatRoomJobExecutions(req ListAnalyzeChatRoomJobExecutionsRequest): ListAnalyzeChatRoomJobExecutionsResponse; @get("/{id=analyzeChatRoomJobs/*/executions/*}") GetAnalyzeChatRoomExecution(req: GetAnalyzeChatRoomExecutionRequest): AnalyzeChatRoomExecution; } interface AnalyzeChatRoomJob { id: string; chatRoom: string; destination: string; compressionFormat: string; } interface AnalyzeChatRoomJobExecution { id: string; job: AnalyzeChatRoomJob; sentenceComplexity: number; sentiment: number; abuseScore: number; } interface CreateAnalyzeChatRoomJobRequest { resource: AnalyzeChatRoomJob; } interface ListAnalyzeChatRoomJobsRequest { filter: string; } interface ListAnalyzeChatRoomJobsResponse { results: AnalyzeChatRoomJob[]; } interface GetAnalyzeChatRoomJobRequest { id: string; } interface UpdateAnalyzeChatRoomJobRequest { resource: AnalyzeChatRoomJob; fieldMask: string; } interface RunAnalyzeChatRoomJobRequest { id: string; } interface RunAnalyzeChatRoomJobMetadata { messagesProcessed: number; messagesCounted: number; } interface ListAnalyzeChatRoomJobExecutionsRequest { parent: string; filter: string; } interface ListAnalyzeChatRoomJobExecutionsResponse { results: AnalyzeChatRoomJobExecution[]; } interface GetAnalyzeChatRoomJobRequest { id: string; }
作为我们已经看到,可重新运行的作业是一个非常有用的概念,但它们是解决问题集的众多方法之一。例如,如果我们关心配置权限与执行自定义方法的权限,我们可以实施一个更高级的权限系统来检查请求并验证给定用户不仅可以调用特定的 API 方法,还可以调用该方法特定的参数集或范围。对于所有相关人员来说,这当然需要更多的工作,但与将方法分成两部分(配置和运行)相比,这是一个更精细的选择。
As we’ve seen, rerunnable jobs are a very useful concept to rely on, but they are one solution of many for the problem set. For instance, if we were concerned about permissions to configure versus execute a custom method, we could implement a more advanced permission system that inspected the request and verified that a given user could not only call a specific API method but could call that method with a specific set or range of parameters. This is certainly much more work for everyone involved, but it’s a more granular option than splitting the method into two pieces (configure and run).
当涉及到执行资源时,我们当然有另一种选择,可以简单地Operation永远保留资源作为输出的参考。唯一的缺点是需要过滤Operation资源列表以检索与感兴趣的资源相关的Job资源,但这绝对是一个选择。
When it comes to execution resources, we certainly have an alternative option to simply keep Operation resources around forever as the reference of the output. The only downside to this is the requirement to filter through the list of Operation resources to retrieve those related to the Job resource of interest, but it is definitely an alternative.
How do rerunnable jobs make it possible to differentiate between permissions to perform an action and permission to configure that action?
If running a job leads to a new resource being created, should the result be an execution or the newly created resource?
Why is it that execution resources should never be explicitly created?
Rerunnable jobs are a great way to isolate users capable of configuring a task from those capable of executing the same task.
Jobs are resources that are first created and configured. Later, these jobs may be executed using a custom run method.
When the job doesn’t operate on or create an existing resource, the result of a Job resource being run is typically an Execution resource.
Execution resources are like read-only resources that may be listed and retrieved but not updated or created explicitly.
在我们设计的任何 API 中,不同的组件需要以不同的方式相互关联。在接下来的几章中,我们将研究资源关联的各种方式,以及如何设计正确的 API 方法以允许用户管理这些资源之间的关系。
In any API we design, different components need to relate to one another in different ways. In the next few chapters, we’ll examine a variety of ways in which resources can associate and how to design the right API methods to allow users to manage those relationships between resources.
首先,在第 12 章中,我们将了解一种特殊类型的资源,称为单例子资源,以及我们如何依赖这个概念来存储具有独特访问模式的隔离数据。在第 13 章中,我们将探讨资源如何相互引用,以及如何维护这些引用的完整性。在第 14 章和第 15 章中,我们将仔细研究管理资源之间多对多关系的两种不同方式(使用关联资源或自定义添加和删除方法)。最后,在第 16 章中,我们将了解多态资源,它为资源提供了一种独特的能力,可以根据特定参数承担多种角色。
First, in chapter 12, we’ll look at a special type of resource called a singleton sub-resource and how we can rely on this concept to store isolated data with unique access patterns. In chapter 13, we’ll explore how resources can reference one another, as well as how to maintain integrity with these references. In chapters 14 and 15, we’ll look closely at two different ways of managing many-to-many relationships between resources (using association resources or custom add and remove methods). Finally, in chapter 16, we’ll look at polymorphic resources that provide a unique ability for resources to take on multiple roles depending on specific parameters.
在此模式中,我们将探索一种结构化相关但独立数据的方法,方法是将数据从资源的一组属性移至该资源的单个子项。此模式处理特定数据集合可能独立于父级更改、具有不同安全要求或可能太大而无法直接作为资源的一部分存储的场景。
In this pattern, we’ll explore a way of structuring related but independent data by moving it from a set of properties on a resource to a singleton child of that resource. This pattern handles scenarios where a specific collection of data might change independently of the parent, have different security requirements, or simply might be too large to store directly as part of the resource.
在构建 API 时,我们有时会遇到这样的情况,即资源的某些组件显然属于资源的属性,但出于某种原因,该组件作为常规属性并不实用。换句话说,根据 API 设计最佳实践,组件绝对最适合作为属性,但在这种情况下遵循这些最佳实践是不切实际的,最终会给使用 API 的人带来更糟糕的体验。例如,如果有表示共享文档的资源,它可能还需要存储访问控制列表 (ACL),以确定谁有权访问该文档以及以何种身份访问。正如您想象的那样,这个 ACL 列表可能会变得非常大(数百甚至数千条访问规则),ListDocuments方法) 通常会浪费带宽和计算资源。此外,在大多数情况下,ACL 的细节甚至不是我们感兴趣的。我们只在非常特定的场景中才关心 ACL。
When building APIs, we sometimes have situations where there are components of resources that obviously belong as properties on the resource, but for one reason or another the component wouldn’t be practical as a regular property. In other words, according to API design best practices the component is definitely best suited to be a property, but it would be impractical to follow those best practices in this case and would end up making a worse experience for those using the API. For example, if there was a resource that represented a shared document, it might also need to store the access control list (ACL) that determines who has access to the document and in what capacity. As you might imagine, this list of ACLs could become immensely large (hundreds or even thousands of access rules), and retrieving the entire ACL every time we browse through the list of documents (e.g., using a ListDocuments method) would often be a waste of bandwidth and compute resources. Further, in most scenarios the details of the ACL aren’t even what we’re interested in. We care about the ACL only in very specific scenarios.
显而易见的答案是像这样分离组件,但这给我们带来了两个不可避免的问题。首先,我们如何决定某些东西是否值得以某种方式分开?其次,一旦我们决定某些东西应该从主要资源中分离出来,我们如何实现这种分离?
The obvious answer is to separate components like this, but this leads us to two inevitable questions. First, how do we decide whether something merits being split apart somehow? Second, once we’ve decided that something should be separated from the main resource, how do we implement this separation?
让我们从关注第一个问题开始,探讨我们可能希望将组件从其父资源移至单独的子资源中的一些可能原因。之后,我们可以深入研究如何使用单例子资源模式来实现这种分离。
Let’s start by focusing on the first question and explore some possible reasons we might want to move components away from their parent resources into separate sub-resources. After that, we can dig into how to implement this separation using the singleton sub-resource pattern.
那里将组件与通常拥有该组件的资源分开的潜在原因有很多。其中一些原因是显而易见的(例如,组件比资源本身大几倍),但其他原因则更为微妙。让我们探讨一些将组件与其父资源分离的常见原因。
There are many potential reasons to separate a component from the resource that would normally own the component. Some of these reasons are obvious (e.g., the component is several times larger than the resource itself), but others are a bit more subtle. Let’s explore a few common reasons for separating components from their parent resources.
经常与资源本身相比,单个组件可能会变得特别大。如果一个组件可能会变得比资源的所有其他部分加起来还要大,那么将该组件与资源分离可能是有意义的。例如,如果您有一个存储大型二进制对象的 API(如 Amazon 的 S3),那么将二进制数据本身与对象的元数据一起存储是不常见的。在这种情况下,您将两者分开:GetObject要么GetObjectMetadata可能会返回有关存储在数据库中的对象的元数据,但明确不包括二进制数据本身。这将改为使用完全不同的 RPC 单独检索,例如GetObjectData.
Often a single component can become particularly large when measured in comparison to the resource itself. If a component will potentially become larger than all other pieces of a resource combined, it may make sense to separate that component from the resource. For example, if you have an API that stores large binary objects (like Amazon’s S3), it would be unusual to store the binary data itself alongside the metadata about the object. In this case, you’d separate the two: GetObject or GetObjectMetadata might return metadata about the object that is stored in a database but explicitly not include the binary data itself. This would instead be retrieved separately with an entirely different RPC, such as GetObjectData.
经常资源的某些部分与它们名义上所属的资源具有非常不同的访问限制。在这种情况下,实际上最终可能会严格要求将此信息与其所属的资源完全分开。例如,您可能有一个表示公司员工数据的 API,但与员工的昵称、家乡和电话号码等更常见的信息相比,附加到每个员工的薪酬信息可能受到更严格的限制。因此,分离该信息可能是最有意义的,无论是作为完全独立的资源还是使用描述的单例子资源模式这里。
Often there are pieces of a resource that have very different access restrictions from the resource they nominally belong to. In cases like these, it may actually end up being a strict requirement that this information be kept entirely separate from the resource to which it would otherwise belong. For example, you might have an API that represents employee data in a company, but the compensation information attached to each employee is likely to be much more highly restricted than more common things such as the employee’s nickname, hometown, and telephone number. As a result, it would probably make the most sense to separate that information, either as a completely separate resource or using the singleton sub-resource pattern described here.
在除了大小和安全问题之外,通常还会出现资源的特定组件具有异常访问模式的情况。特别是,如果某个组件比资源上的其他组件更频繁地更新,将它们放在一起可能会导致大量写入争用,最终可能导致写入冲突,或者在极端情况下导致数据丢失. 例如,让我们想象一个有Driver资源的拼车 API. 当司机的车在移动时,我们希望尽可能频繁地更新资源的位置信息,以显示最新的位置和司机的移动。另一方面,更一般的元数据,例如车牌,可能不会经常更改,也许根本不会更改。因此,将频繁变化的位置信息与其他元数据分开是有意义的,这样更新就可以独立完成。
In addition to size and security concerns, there are often scenarios where specific components of a resource have unusual access patterns. In particular, if there is a component that is updated much more frequently than the other components on a resource, keeping these together could lead to a significant amount of write contention, which could ultimately lead to write conflicts or, in extreme cases, data loss. For example, let’s imagine a ride-sharing API where there is a Driver resource. When the driver’s car is moving, we’ll want to update the resource’s location information as frequently as possible to show the latest location and the driver’s movement. On the other hand, more general metadata, such as the license plate, might not change very frequently, maybe not at all. As a result, it would make sense to separate the frequently changing location information from the other metadata so that updates can be done independently.
显然这不是一个详尽的列表,因为将组件与资源分开的原因有很多,但这些类别是一些最常见的场景。现在我们已经了解了我们可能将事物分开的一些一般原因,让我们更详细地了解我们如何使用 API 在 API 中对这种分离进行建模单例子资源模式。
Obviously this is not an exhaustive list, as there are many reasons for separating components from resources, but these categories are some of the most frequently seen scenarios. Now that we have an understanding of some general reasons we might split things apart, let’s look in more detail at how we might model this separation in an API using the singleton sub-resource pattern.
自从我们的目标是将资源的一个组件从一个简单的属性移动到某种独立的实体,我们可以采用的一种策略是设计一个接口,使得所讨论的组件是成熟资源和资源之间的混合体资源的简单属性。本质上,我们可以创建具有完整资源的某些特征和行为以及简单资源属性的其他特征和行为的东西。
Since our goal is to move a component of a resource from a simple property to some sort of separate entity, one tactic we could employ is to design an interface such that the component in question is a hybrid of sorts between a full-fledged resource and a simple attribute on a resource. In essence, we can create something that has some characteristics and behaviors of a full resource and others of a simple resource property.
为此,我们需要定义一个单例子资源的概念,它具有一些独特的属性和交互方式(图12.1)。这个单例子资源将充当简单属性和完整资源之间的混合类型,这意味着我们将对我们对资源的期望施加一些限制,并添加一些能力来增强我们对资源的期望特性。
To accomplish this, we need to define a concept of a singleton sub-resource, which has some unique properties and interaction methods (figure 12.1). This singleton sub-resource will act as a hybrid of sorts between a simple property and a complete resource, which means we’ll impose some limitations on what we’ve come to expect from resources and add some abilities to enhance what we expect from resource properties.
图 12.1 将 location 属性移动到 Location 单例子资源的资源
Figure 12.1 Resource moving the location property to a Location singleton sub-resource
现在我们了解了这种模式如何工作的高级概念,让我们看一下这些单例子资源应该如何工作的更多细节表现。
Now that we understand the high-level idea of how this pattern works, let’s look at some more specifics about how these singleton sub-resources should behave.
一次我们已经决定应该将一些东西从一个简单的资源属性拆分成一个单独的单例子资源,我们需要弄清楚与这些子资源交互的界面到底是什么样的。正如你在表 12.1 中看到的,如果我们通过查看我们之前了解的标准方法集来考虑与单例子资源交互,很明显某些方法采用常规资源的行为,而其他方法采用常规资源的行为一个简单的资源属性y。
Once we’ve decided that something should be split from a simple resource property into a separate singleton sub-resource, we need to figure out exactly what the interface for interacting with these sub-resources looks like. As you can see in table 12.1, if we think of interacting with a singleton sub-resource by looking at the set of standard methods we learned about earlier, it’s clear that certain methods take on the behavior of a regular resource and others take on that of a simple resource property.
Table 12.1 Summary behavior of singleton sub-resources
作为我们可以在表 12.1 中看到,检索(获取)和修改(更新)标准方法的行为与它们在任何其他资源上的行为一样。这意味着单例子资源可以像常规资源一样使用标识符进行唯一寻址,并且可以使用部分属性替换(例如,通过PATCHHTTP 动词)以传统方式进行修改。为了完全清楚,下面的序列图(见图 12.2)显示了我们如何检索和修改单例子资源。
As we can see in table 12.1, both retrieval (get) and modification (update) standard methods act just like they would on any other resource. This means that singleton sub-resources are uniquely addressable with an identifier as regular resources are and can be modified in the traditional way using a partial replacement of properties (e.g., via the PATCH HTTP verb). For complete clarity, the following sequence diagram (see figure 12.2) shows how we might retrieve and modify a singleton sub-resource.
Figure 12.2 Summary of interaction with singleton sub-resource
最终,这意味着我们为单例子资源定义的获取和更新方法将与常规资源的方法相同。重要的是要记住,寻址单例子资源是通过唯一标识符而不是使用父资源的标识符来完成的。换句话说,像任何其他检索方法一样GetDriverLocation接受唯一标识符(例如, ),而不是我们在列表中看到的父标识符drivers/1/location方法。
Ultimately, this means that the get and update methods we’d define for a singleton sub-resource would be identical to those of a regular resource. It’s important to remember that addressing the singleton sub-resource is done by a unique identifier rather than using the identifier of the parent resource. In other words, GetDriverLocation accepts a unique identifier (e.g., drivers/1/location) like any other retrieval method and not a parent identifier as we’ve seen in list methods.
继续通过表 12.1,我们可以看到 create 方法更像是一个属性。这意味着单例子资源仅凭借其父资源的存在而存在。换句话说,就像不需要在资源上显式创建属性(例如,Driver.licensePlate属性)一样,也不需要显式创建DriverLocation资源。相反,它必须恰好在父资源存在时存在。
Continuing with table 12.1, we can see that the create method acts more like a property. This means that singleton sub-resources simply exist by virtue of their parent existing. In other words, just as there’s no need to explicitly create properties on a resource (e.g., a Driver.licensePlate property), there’s also no need to explicitly create a DriverLocation resource. Instead, it must come into existence exactly when the parent resource does.
这也意味着必须不需要显式初始化才能与单例子资源交互。这并不是说在创建父资源后永远不需要更新子资源的属性;它是说不应该为了开始更新子资源本身而需要执行特定的初始化方法。图 12.3 显示了与创建单例子资源的父资源交互的示例。
This also means that there must be no explicit initialization required in order to interact with the singleton sub-resource. This isn’t to say that there will never be a need to update properties of the sub-resource after the parent has been created; it is saying that there should not be a specific initialization method that needs to be executed in order to begin updating the sub-resource itself. A sample interaction with a parent resource creating a singleton sub-resource is shown in figure 12.3.
Figure 12.3 Creating a parent resource causes a singleton sub-resource to be created.
这就引出了一个明显的问题:我们如何才能在创建父资源时准确地初始化存储在单例子资源中的数据?换句话说,有没有一种方法可以创建资源并在创建资源时准确地Driver初始化信息?简单回答是不。DriverLocationDriver
This leads to an obvious question: how can we initialize the data stored in a singleton sub-resource exactly when we create the parent resource? In other words, is there a way to create a Driver resource and initialize the DriverLocation information exactly when the Driver resource is created? The simple answer is no.
将某物分离为子资源的全部意义在于以某种方式将其与父资源隔离开来。这可能是为了响应第 12.1 节中讨论的任何主题(例如,易变性),但这种分离的结果是子资源通常与父资源分开。虽然父资源的创建意味着子资源的存在,但对存储在子资源中的信息的任何更改都需要单独完成,因为这两者最终是完全独立的资源。换句话说,与通过单个方法创建两个单独的资源相比,创建一个子资源Driver 并设置DriverLocation子资源中的信息不再可能。CreateDriverDriverCreateDriver称呼。这意味着为单例子资源选择合理的默认值很重要,这样它们即使在隐式时也可能有用初始化。
The entire point of separating something into a sub-resource is to isolate it in some way from the parent resource. This could be in response to any of the topics discussed in section 12.1 (e.g., volatility), but the consequences of this separation are that the sub-resource is kept generally apart from the parent. While the creation of the parent implies the existence of the sub-resource, any changes to the information stored in the sub-resource would need to be done separately as the two are ultimately completely separate resources. In other words, creating a Driver and setting the information in the DriverLocation sub-resource isn’t any more possible in a single CreateDriver method than creating two individual Driver resources through a single CreateDriver call. This means that it’s important to choose reasonable defaults for singleton sub-resources so that they might be useful even when implicitly initialized.
只是由于创建父资源会导致创建单例子资源,因此删除该父资源也必须删除单例子资源。换句话说,有点像关系数据库如何允许基于外键约束的级联删除,父资源的删除向下级联到附加的任何单例子资源。从本质上讲,这意味着在删除时,单例子资源更像是属性而不是单独的资源。为了证明这一点,图中显示了级联删除的示例交互。12.4。
Just as creating a parent resource causes a singleton sub-resource to be created, deleting that parent resource must also delete the singleton sub-resource. In other words, sort of like how relational databases allow cascading deletes based on foreign key constraints, deletes of parent resources cascade downward to any singleton sub-resources attached. In essence, this means that when it comes to deleting, singleton sub-resources act more like properties than separate resources. To demonstrate this, a sample interaction of a cascading delete is shown in figure. 12.4.
Figure 12.4 The singleton sub-resource is deleted along with the parent resource.
警告如果级联删除令人惊讶或不符合典型消费者的期望,这通常是一个好兆头,表明单例子资源模式不适合用例。
WARNING If a cascading deletion would be surprising or not fit with the expectations of a typical consumer, this is often a good sign that the singleton sub-resource pattern isn’t a great fit for the use case.
这涵盖了标准删除操作,但它也导致了一些关于非标准删除的重要问题,例如,“当删除父对象不是即时的并且使用 LRO(参见第 10 章)或依赖于软删除时我们该怎么办?模式(第 6.1 节)?由于删除会导致子资源主要表现为属性而不是单独的资源,因此我们可以使用非标准行为将其转移。这意味着已标记为删除的父资源将仍然存在,直到父删除停止存在。同样,如果删除父资源可能需要一段时间,因此依赖于 LRO 来跟踪该删除的进度,则只能在删除父资源的操作后删除子资源资源完成。
This covers the standard deletion operation, but it also leads to some important questions about nonstandard deletion, such as, “What do we do when deleting the parent is not instantaneous and uses an LRO (see chapter 10) or relies on the soft-deletion pattern (section 6.1)?” Since deleting causes the sub-resource to act primarily like a property and not like a separate resource, we can transfer that over with the nonstandard behavior. This means that a parent resource that has been flagged for deletion would still exist until the parent deletion stops existing. Likewise, if deleting a parent resource might take a while and therefore relies on an LRO to track the progress of that deletion, the sub-resource must only be deleted once the operation to delete the parent resource completes.
在在某些情况下,可能需要将单例子资源本身恢复为一组合理的默认值,特别是在首次创建父资源时设置的默认值。在这种情况下,API 应该支持一个重置方法,该方法会自动将这些值设置为其原始默认值,就好像单例子资源在创建父资源时刚刚存在一样。重置子资源的示例交互显示在图 12.5。
In some cases there may be a need to revert the singleton sub-resource itself to a set of reasonable default values, in particular the ones that were set when the parent resource was first created. In that case, the API should support a reset method that would atomically set those values to their original defaults as though the singleton sub-resource had just come into existence as the parent was created. A sample interaction of resetting the sub-resource is shown in figure 12.5.
图 12.5 重置单例子资源,如果实现,必须恢复合理的默认值。
Figure 12.5 Resetting the singleton sub-resource, if implemented, must restore reasonable default values.
现在为了了解如何与单例子资源交互,我们必须澄清一些关于这些单例与其父级之间的层次关系的重要细节。
Now that we understand how to interact with singleton sub-resources, it’s important that we clarify a few important details about the hierarchical relationship between these singletons and their parents.
作为顾名思义,子资源应该从属于某个父资源。这意味着单例子资源应始终具有父资源,并且不应附加在 API 层次结构的根级别。这样做的原因是全局单例在功能上等同于全局共享锁,资源的单个实例在 API 的所有潜在使用者之间共享。正如您可能想象的那样,在 API 的所有潜在使用者之间共享全局状态会引入严重的写入争用,将所有写入流量集中到一个资源。
As the name implies, sub-resources should be subordinate to some parent resource. This means that a singleton sub-resource should always have a parent resource and should not be attached at the root level of the hierarchy of an API. The reason for this is that global singletons are functionally equivalent to a global shared lock, with a single instance of a resource shared across all of the potential consumers of the API. As you might imagine, sharing global state across all the potential consumers of an API introduces significant write contention, focusing all write traffic toward a single resource.
其他要考虑的常见边缘情况是单例子资源是否可以拥有自己的单例子资源。简而言之,如果一个单例子资源充当其他单例的父级,则这些应该成为父级本身的兄弟姐妹。简而言之,让一个单身人士充当父母真的没有任何附加值到其他。
Another common edge case to consider is whether singleton sub-resources can have singleton sub-resources of their own. In short, if a singleton sub-resource acts as a parent to other singletons, these should instead be made siblings of the parent itself. Put simply, there really isn’t any value added in having one singleton act as a parent to another.
服用这个实现的所有细节,我们可以为我们的示例服务定义 API,我们的Driver资源有DriverLocation单例子资源。显示了这个最终的 API 定义在清单 12。1.
Taking all of the details from this implementation, we can define the API for our example service where we have Driver resources that have DriverLocation singleton sub-resources. This final API definition is shown in listing 12.1.
Listing 12.1 Final API definition using the singleton sub-resource pattern
abstract class RideSharingApi { static version = "v1"; static title = "Ride Sharing API"; @get("/drivers") ListDrivers(req: ListDriversRequest): ListDriversResponse; @post("/drivers") CreateDriver(req: CreateDriverRequest): Driver; @get("/{id=drivers/*}") GetDriver(req: GetDriverRequest): Driver; @patch("/{resource.id=drivers/*}") UpdateDriver(req: UpdateDriverRequest): Driver; @delete("/{id=drivers/*}") DeleteDriver(req: DeleteDriverRequest): void; @get("/{id=drivers/*/location}") GetDriverLocation(req: GetDriverLocationRequest): DriverLocation; @patch("/{resource.id=drivers/*/location}") UpdateDriverLocation(req: UpdateDriverLocationRequest): DriverLocation; } interface Driver { id: string; name: string; licensePlate: string; ❶ } interface DriverLocation { id: string; ❷ lat: number; long: number; updateTime: Date; } interface GetDriverRequest { id: string: } interface UpdateDriverRequest { resource: Driver; fieldMask: FieldMask; } interface ListDriversRequest { maxPageSize: number; pageToken: string; } interface ListDriversResponse { results: Driver[]; nextPageToken: string; } interface CreateDriver { driver: Driver; } interface DeleteDriverRequest { id: string; } interface GetDriverLocationRequest { id: string; } interface UpdateDriverLocationRequest { resource: DriverLocation; fieldMask: FieldMask; }
❶请注意,驱动程序的位置未设置为位置属性。它被分成一个单独的子资源。
❶ Note that the driver’s location is not set as a location property. It’s separated into a singleton sub-resource.
❷请记住,DriverLocation 资源的唯一标识符完全依赖于 Driver 资源的唯一标识符。换句话说,一个名为 drivers/1 的 Driver 将有一个名为 drivers/1/location 的 DriverLocation。
❷ Remember that the unique identifier of the DriverLocation resource is dependent entirely on that of the Driver resource. In other words, a Driver called drivers/1 would have a DriverLocation called drivers/1/location.
作为我们在实施这种模式时了解到,使用这种设计会带来一些权衡,特别是我们会失去的东西。让我们更深入地研究一下,从父资源和子资源的原子性开始。
As we learned when implementing this pattern, there are a few trade-offs that come with using this design, specifically things we lose. Let’s dig into these a bit more deeply, starting with atomicity on a parent and sub-resource.
什么时候使用单例子资源模式,我们看到子资源具有属性的一些特征和资源的其他特征,特别是创建行为像属性(我们从不创建单例子资源;它只是存在)和其他更像是一个实际的资源(我们直接更新资源而不是通过寻址其父资源)。这样做的一个副作用是我们不再有办法同时与资源和子资源交互。换句话说,我们没有办法同时与父资源和子资源进行原子交互。
When using the singleton sub-resource pattern, we saw that the sub-resource had some characteristics of a property and others of a resource, specifically that creating acted like a property (we never create a singleton sub-resource; it just exists) and others acted more like an actual resource (we update a resource directly rather than by addressing its parent). One side effect of this is that there is no longer a way for us to interact with both the resource and the sub-resource together. In other words, we don’t have a way to atomically interact with both the parent resource and the sub-resource at the same time.
虽然这肯定是一个限制(毕竟,当DriverLocation信息是Driver资源的属性时,我们可以自动创建Driver具有特定位置的资源),但这个限制是设计使然的。我们将子资源与父资源分开的主要目标是隔离这组特定的信息,可能是因为它很大,也可能是因为它有非常具体的安全要求。换句话说,这种权衡更多的是作为一个功能,即使它可能意味着以前很容易做的某些常见动作不再是可能的。
Though this is certainly a limitation (after all, when DriverLocation information was a property on a Driver resource we could atomically create a Driver resource with a specific location), this limitation is by design. Our main goal in separating the sub-resource from the parent resource is to isolate this specific set of information, maybe because it’s large or maybe because it has very specific security requirements. In other words, this trade-off is meant more as a feature, even though it might mean that certain common actions that were easy to do before are no longer possible.
其他依赖单例子资源模式的重要权衡是永远只能有一个子资源。这意味着,与可以包含许多相同类型的子资源的传统子集合不同,一旦我们采用了这种模式,就永远只能有一个这样的实例具体的子资源。
Another important trade-off of relying on the singleton sub-resource pattern is that there can only ever be one of the sub-resources. This means that, unlike a traditional subcollection that can contain many sub-resources of the same type, once we’ve adopted this pattern there can only ever be one instance of this specific sub-resource.
How big does attribute data need to get before it makes sense to split it off into a separate singleton sub-resource? What considerations go into making your decision?
Why do singleton sub-resources only support two of the standard methods (get and update)?
Why do singleton sub-resources support a custom reset method rather than repurposing the standard delete method to accomplish the same goal?
Why can’t singleton sub-resources be children of other singleton sub-resources in the resource hierarchy?
Singleton sub-resources are hybrids between properties and resources, storing data that is inherent to a resource but in a separate isolated location.
由于各种原因,例如大小、复杂性、单独的安全要求或不同的访问模式和由此产生的波动性,数据可能会从资源中分离到单个子资源中。
Data might be separated from a resource into a singleton sub-resource for a variety of reasons, such as size, complexity, separate security requirements, or differing access patterns and the resulting volatility.
Singleton sub-resources support the standard get and update methods, but they should never be created or deleted and therefore should not support the standard create or delete methods.
Singleton sub-resources should generally also support a custom reset method, which restores the resource’s attributes to their default states.
Singleton sub-resources should be attached to a parent resource, and that resource should not, itself, be another singleton sub-resource.
在具有多种资源类型的任何 API 中,很可能需要资源相互指向。尽管这种引用资源的方式可能看起来微不足道,但许多行为细节都留待解释,这意味着存在不一致的机会。此模式旨在阐明应如何定义这些引用,更重要的是,阐明它们的行为方式。
In any API with multiple resource types, it’s likely that there will be a need for resources to point at one another. Though this manner of referencing resources may appear trivial, many of the behavioral details are left open for interpretation, which means there is the opportunity for inconsistency. This pattern aims to clarify how these references should be defined and, more importantly, how they should behave.
资源很少存在于真空中。因此,资源必须有一种相互引用的方法。这些引用的范围从本地(例如,同一 API 中的其他资源)到全局(例如,更广泛互联网上其他地方的资源),也可能介于两者之间(例如,同一 API 提供的不同 API 中的资源)提供商)(图 13.1)。
Resources rarely live in a vacuum. As a result, there must be a way for resources to reference one another. These references range from the local (e.g., other resources in the same API) to the global (e.g., resources that live elsewhere on the wider internet) and may fall in between as well (e.g., resources in a different API offered by the same provider) (figure 13.1).
图 13.1 资源可以指向同一 API 或外部 API 中的其他资源。
Figure 13.1 Resources can point at others in the same API or in external APIs.
虽然通过唯一标识符简单地引用这些资源似乎很明显,但行为方面在很大程度上留给了实施者来确定。因此,我们需要为引用资源和支撑这些引用的行为模式定义一组指南。例如,是否应该允许您删除被指向的内容,还是应该禁止删除?如果允许的话,鉴于它指向的资源已经消失,是否应该对该值进行任何后处理(例如将其重置为零值)?还是应该单独留下无效指针?
While it might seem obvious to simply refer to these resources by a unique identifier, the behavioral aspects are largely left to the implementer to determine. As a result, we’ll need to define a set of guidelines for referring to resources and the patterns of behavior underpinning those references. For example, should you be allowed to delete something that is being pointed at or should this be prohibited? If that’s allowed, should there be any postprocessing on the value (such as resetting it to the zero value) given that the resource it pointed to has gone away? Or should the invalid pointer be left alone?
简而言之,虽然引用资源背后的总体思路非常简单,但细节可能相当复杂。
In short, while the general idea behind referencing resources is quite simple, the details can be fairly complicated.
作为您可能会想到,交叉引用模式依赖于一个资源上指向另一个资源的引用属性,使用字段名称来暗示资源类型。此引用使用表示为字符串值(请参阅第 6 章)的唯一标识符来表示,因为它可以引用同一 API 中的资源、同一提供商的不同 API 或作为标准 URI 存在于 Internet 其他地方的完全独立的资源。
As you might expect, the cross-reference pattern relies on a reference property on one resource that points to another, using the field name to imply the resource type. This reference is represented using a unique identifier represented as a string value (see chapter 6) as it can then reference resources in the same API, different APIs by the same provider, or entirely separate resources living elsewhere on the internet as a standard URI.
此外,此引用与其指向的资源完全分离。这允许在操作资源时具有最大的灵活性,防止循环引用锁定资源存在,并在单个资源被数千个其他资源引用的情况下进行扩展。然而,这也意味着消费者必须预料到指针可能已过时并且在所引用的基础资源已被移动或可能无效的情况下删除。
Additionally, this reference is completely decoupled from the resource it points to. This allows for maximum flexibility when manipulating resources, prevents circular references from locking resources into existence, and scales in cases where a single resource is referenced by many thousands of others. However, this also means that consumers must expect that pointers may be out of date and potentially invalid in cases where the underlying resource being referred to has been moved or deleted.
那里是该模式的几个重要方面。在本节中,我们将更详细地探讨关于一个资源应如何引用另一个资源的各种必须回答的问题。让我们首先看一个示例引用,以及我们应该如何命名该字段以引用另一个资源。
There are several important aspects to this pattern. In this section, we’ll explore in much more detail the various questions that must be answered regarding how one resource should reference another. Let’s start by looking at an example reference and how we should name the field to refer to another resource.
尽管有可能使用的唯一标识符可以传达被引用资源的类型和用途,使用字段名称来传达这两个方面是一个更安全的选择。例如,如果我们有一个Book资源指的是Author资源,我们应该将存储引用的字段命名为authorId传达它是Author资源的唯一标识符。
While it’s possible that the unique identifier used can convey the type and purpose of the resource being referenced, it’s a much safer option to use the name of the field to convey both of these aspects. For example, if we have a Book resource that refers to an Author resource, we should name the field storing the reference as authorId to convey that it’s the unique identifier of an Author resource.
Listing 13.1 Definition of Author and Book resources
interface Book { id: string; authorId: string; ❶ title: string; // ... ❷ } interface Author { id: string; ❸ name: string; }
❶我们使用一个包含作者唯一标识符的字符串字段来存储对作者资源的引用。
❶ We store a reference to the Author resource using a string field holding a unique identifier of an author.
❷ We’ll leave out extra fields for brevity.
❸作者资源的唯一标识符。这是出现在 authorId 字段中的值。
❸ The unique identifier of the Author resource. This is the value that appears in the authorId field.
大多数情况下,我们将引用的资源是静态类型,这意味着我们可能指向不同Author的资源,但相关资源的类型始终是作者。然而,在某些情况下,我们指向的资源类型可能因情况而异,我们称之为动态资源类型参考。
Most times the resource we’ll refer to will be a static type, meaning that we might point to different Author resources, but the type of the resource in question will always be an author. In some cases, however, the type of the resource we’re pointing to can vary from case to case, which we’ll call dynamic resource type references.
例如,我们可能想要存储作者和书籍的更改历史记录。这意味着我们不仅需要能够指向许多不同的资源,而且还需要能够指向许多不同的资源类型。为此,我们应该依赖一个额外的type字段指定所讨论的目标资源的类型。
For example, we may want to store a change history of authors and books. This means we’ll need the ability to point to not just many different resources, but many different resource types. To do this, we should rely on an additional type field that specifies the type of the target resource in question.
清单 13.2ChangeLogEntry具有动态资源类型的示例资源
Listing 13.2 Example ChangeLogEntry resource with dynamic resource types
interface ChangeLogEntry { id: string; targetId: string; ❶ targetType: string; ❷ // ... ❸ }
❶ The unique identifier of the target resource (e.g., the author ID)
❷目标资源的类型(例如,api.mycompany.com/Author)
❷ The type of the target resource (e.g., api.mycompany.com/Author)
❸在这里,我们将存储有关对目标资源的此特定更改的更多详细信息。
❸ Here we’d store more details about this particular change to the target resource.
现在我们已经了解了如何定义引用字段,让我们更深入地了解行为的一个重要方面,称为数据 诚信。
Now that we’ve seen how to define a reference field, let’s look more deeply at an important aspect of behavior called data integrity.
自从我们使用简单的字符串值从一个资源指向另一个资源,我们不得不担心这种数据类型提供的自由:没有类型级验证。例如,假设有一些书籍和作者资源的事件顺序如下。
Since we’re using simple string values to point from one resource to another, we have to worry about the freedom provided by this data type: there’s no type-level validation. For example, imagine the following order of events with some book and author resources.
Listing 13.3 Deleting an author referred to by a book
author = CreateAuthor({ name: "Michelle Obama" }); book = CreateBook({ ❶ title: "Becoming", authorId: author.id }); DeleteAuthor({ id: author.id }); ❷
❶在这里,我们创建了一个作者,然后创建了一本引用该作者的书。
❶ Here we create an author and then a book referring to that author.
❷ After that, we delete the Author resource from our API.
在这一点上,我们有所谓的悬空指针(有时称为孤立记录),其中书籍资源指向不再存在的作者。在这种情况下应该发生什么?有几个选项:
At this point, we have what’s called a dangling pointer (sometimes known as an orphaned record), where the book resource is pointing to an author that no longer exists. What should happen in this scenario? There are a few options:
We can prohibit the deletion of the author and throw an error.
We can allow the deletion of the author but set the authorId field to a zero value
We can allow the deletion of the author and deal with the bad pointer at runtime.
如果我们选择禁止删除作者(或者,就此而言,任何仍然在系统中注册了书籍的作者),我们冒着给 API 消费者带来严重不便的风险,他们可能需要删除成百上千本书籍删除单个作者。此外,如果我们有两个相互指向的资源,我们将永远无法删除其中任何一个。例如,考虑是否每个Author资源都有一个附加字段来表示他们最喜欢的书,并且(自私的)作者有自己的书作为最喜欢的书,如图 13.2 所示。在这种情况下,我们永远无法删除这本书,因为作者已将其列为收藏夹。我们也永远不能删除作者,因为仍然有指向该作者的书。
If we choose to prohibit the deletion of the author (or, for that matter, any author who still has books registered in the system), we run the risk of serious inconvenience to API consumers who may need to delete hundreds or thousands of books just to delete a single author. Further, if we had two resources that pointed at one another, we would never be able to delete either of them. For example, consider if each Author resource had an additional field for their favorite book and (being selfish) an author had their own book as a favorite, shown in figure 13.2. In this scenario, we could never delete the book because the author has it listed as a favorite. We could also never delete the author because there are still books that point to that author.
Figure 13.2 It’s possible to have circular references between two resources.
如果我们选择通过将作者指针重置为零值来允许删除,我们就避免了这个循环引用问题;但是,由于一次调用,我们可能不得不更新潜在的大量资源。例如,想象一位特别多产的作家,他写了成百上千本书。在那种情况下,删除单个作者资源实际上会涉及更新数千条记录。这不仅可能需要一段时间,而且系统可能无法以原子方式执行此操作。如果它不能以原子方式完成,那么当整点是为了避免任何无效引用时,我们就会冒着留下悬空指针的风险。
If we choose to allow the deletion by resetting the author pointer to a zero value, we avoid this circular reference problem; however, we may have to update a potentially large number of resources due to a single call. For example, imagine one particularly prolific author who has written hundreds or even thousands of books. In that case, deleting a single author resource would actually involve updating many thousands of records. This not only might take a while, but it may be the case that this isn’t something the system can do in an atomic manner. And if it can’t be done atomically, then we run the risk of leaving dangling pointers around when the whole point is to avoid any invalid references.
这些问题留给我们第三种选择:简单地要求 API 消费者期望引用字段可能无效或指向可能已被删除的资源。虽然这可能不方便,但它为消费者提供了最一致的行为,并具有明确和简单的期望:应检查参考资料。它还不违反标准方法强加的任何约束,例如删除是原子的并且不包含副作用。
These issues leave us with the third option: simply ask API consumers to expect that reference fields might be invalid or point to resources that may have been deleted. While this might be inconvenient, it provides the most consistent behavior to consumers with clear and simple expectations: references should be checked. It also doesn’t violate any of the constraints imposed by standard methods, such as deletes being atomic and containing no side effects.
既然我们已经探索了面对基础数据更改时引用应该如何表现,让我们看一下关于引用与缓存的常见问题值。
Now that we’ve explored how references should behave in the face of changes to the underlying data, let’s look at a commonly raised concern about references versus cached values.
所以到目前为止,我们一直假设字符串标识符引用应该用于引用 API 中的其他资源,但这显然不是唯一的选择。相反,当考虑如何在 API 的其他地方引用事物时,首要问题之一是存储指向其他资源的简单指针还是存储资源本身的缓存副本。在许多编程语言中,这就是引用传递之间的区别或按值传递,前者传递指向内存中某物的指针,后者传递相关数据的副本。
So far we’ve operated on the assumption that string identifier references should be used to refer to other resources in an API, but that’s clearly not the only option. On the contrary, when considering how we can refer to things in other places of the API, one of the first questions is whether to store a simple pointer to the other resource or a cached copy of the resource itself. In many programming languages, this is the difference between pass by reference or pass by value, where the former passes around a pointer to something in memory and the latter passes a copy of the data in question.
这里的区别在于,通过参考,我们始终可以确保手头的数据是最新的和最新的。另一方面,无论何时出现副本,我们都必须关心我们手头的数据自上次检索以来是否发生了变化。另一方面,存储对资源的引用仅意味着使用 API 的任何人都必须发出第二个请求以检索该位置的数据。当我们将这比作在我们面前拥有一份数据副本时,光是便利就显得很诱人。例如,为了检索某本书的给定作者的姓名,我们需要执行两个单独的 API 调用。
The distinguishing factor here is that by relying on references, we can always be sure that the data we have on hand is fresh and up-to-date. On the other hand, whenever copies enter the picture we have to concern ourselves with whether the data we have on hand has changed since we last retrieved it. On the flip side, storing a reference to the resource only means that anyone using the API must make a second request to retrieve the data at that location. When we compare this to having a copy of the data right in front of us, the convenience alone seems tempting. For example, in order to retrieve the name of a given author of a book, we’ll need to do two separate API calls.
Listing 13.4 Retrieving a book author’s name with two API calls
book = GetBook({ ❶ "id: "books/1234" }); authorName = GetAuthor({ id: book.authorId }).name; ❷
❶ Here we retrieve a book by a (random) unique identifier.
❷ To see the name of the author, we need a separate API call.
这两个调用虽然简单,但仍然是我们在返回完整Author资源的替代设计中所需数量的两倍在检索书籍时的响应中。
These two calls, while simple, are still double the number we’d need in the alternative design where we return the full Author resource in the response when retrieving a book.
Listing 13.5 Redefining the Book and Author resources to use values
interface Book { id: string; title: string; author: Author; ❶ } interface Author { id: string; name: string; }
❶这里我们存储了整个 Author 资源值,而不是对作者的引用。
❶ Here we have the entire Author resource value stored rather than a reference to the author.
虽然我们现在无需第二次 API 调用就可以立即访问该书的作者信息,但我们必须决定如何在服务器端填充该数据。我们可以在每次GetBook请求时从我们的数据库中检索作者信息和图书信息(例如,使用 SQLJOIN语句),或者我们可以在图书资源中存储作者信息的缓存副本。前者会给数据库带来更多的负载,但会保证所有信息在请求时都是最新的,而后者会避免这个问题,但会引入数据一致性问题,我们现在必须想出一个策略来保持作者的这个缓存信息新鲜和最新。
While we now have immediate access to the book’s author information without a second API call, we have to make a decision about how to populate that data on the server side. We could either retrieve the author information alongside the book information from our database at the time of each GetBook request (e.g., use a SQL JOIN statement), or we could store a cached copy of the author information within the book resource. The former will put more load on the database but will guarantee that all information is fresh when requested while the latter will avoid that problem but introduce a data consistency problem where we now have to come up with a strategy of how to keep this cache of author information fresh and up-to-date.
此外,我们现在需要处理这样一个事实,即GetBook每次Author资源增长时响应的大小都会增长。如果我们对一本书的其他方面采用这种策略(例如,如果我们开始存储有关给定图书的出版商的信息),那么大小可能会继续增长甚至进一步增长,可能会失控。
Further, we now need to deal with the fact that the size of the GetBook response will grow every time the Author resource grows. If we follow this strategy with other aspects of a book (e.g., if we start storing the information about the publisher of a given book), then the size can continue to grow even further, potentially getting out of hand.
最后,当期望消费者对Book资源进行修改时,这种类型的模式可能会造成混淆。Book他们可以通过更新资源本身来更新作者姓名吗?他们如何更新作者?
Finally, this type of schema can cause confusion when consumers are expected to make modifications to the Book resource. Can they update the author’s name by updating the Book resource itself? How do they go about updating the author?
Listing 13.6 Example code snippet with annotation
author = CreateAuthor({ name: "Michelle Robinson" }); book = CreateBook({ ❶ title: "Becoming", author: { id: author.id } }); UpdateBook({ ❷ id: book.id, author: { name: "Michelle Obama" } });
❶创建一本书需要一些不寻常的语法,我们只设置作者的 ID 字段并允许自动填充其余部分。
❶ Creating a book requires some unusual syntax where we set the author’s ID field only and allow the rest to be populated automatically.
❷ Is this valid? Can we update the name of the author by updating the book?
由于所有这些潜在的问题和棘手的问题,通常最好单独使用引用,然后依靠 GraphQL 之类的东西将这些不同的引用拼接在一起。这允许 API 消费者运行单个查询并获取他们想要的关于给定资源和该资源引用的所有(并且完全是所有)信息,避免膨胀并消除缓存任何的需要信息。
As a result of all these potential issues and thorny questions, it’s usually best to use references alone and then rely on something like GraphQL to stitch these various references together. This allows API consumers to run a single query and fetch all (and exactly all) the information they want about a given resource and those referenced by that resource, avoiding the bloat and removing the need to cache any information.
我们可以看到最终的一组接口,这些接口说明了如何从一个资源中正确引用资源到其他。
We can see the final set of interfaces that illustrate how to properly reference resources from one resource to another.
Listing 13.7 Final API definition
interface Book { id: string; authorId: string; title: string; } interface Author { id: string; name: string; } interface ChangeLogEntry { id: string; targetId: string; targetType: string; description: string; }
作为前面提到过,我们依赖引用所遭受的主要权衡是要求我们要么进行多次 API 调用以获取相关信息,要么使用 GraphQL 之类的东西来检索我们感兴趣的所有信息在。
As noted previously, the main trade-off we suffer by relying on references is the requirement that we either make multiple API calls to get related information or use something like GraphQL to retrieve all the information we’re interested in.
When does it make sense to store a copy of a foreign resource’s data rather than a reference?
Why is it untenable to maintain referential integrity in an API system?
API 声称可确保引用在整个 API 中保持最新。后来,随着系统的发展,他们决定放弃这条规则。为什么这是一件危险的事情做?
An API claims to ensure references will stay up-to-date across an API. Later, as the system grows, they decide to drop this rule. Why is this a dangerous thing to do?
存储引用的字段通常应该是字符串字段(即,无论标识符的类型是什么)并且应该以“ID”后缀结尾(例如,authorId)。显然,持有外国资源数据副本的字段应省略此后缀。
Fields storing a reference should generally be string fields (i.e., whatever the type of the identifier is) and should end with a suffix of “ID” (e.g., authorId). Fields holding a copy of foreign resource data should, obviously, omit this suffix.
References should generally not expect to be maintained over the lifetime of the resource. If other resources are deleted, references may become invalid.
Resource data may be stored in-line in the referencing resource. This resource data may become stale as the data being referenced changes over time.
在本章中,我们将探索一种模式,用于对两个资源之间的多对多关系进行建模,使用单独的关联资源来表示两者之间的连接。这种模式允许消费者显式地处理两个资源之间的个体关系,以及存储和管理关于该关系的额外元数据。
In this chapter, we’ll explore a pattern for modeling many-to-many relationships between two resources using a separate association resource to represent the connection between the two. This pattern allows consumers to explicitly address an individual relationship between two resources as well as storing and managing extra metadata about that relationship.
大多数时候,我们将在 API 中定义的资源之间的关系简单明了,因为它们本质上往往是单向的,充当从一个资源到另一个资源的指针或引用(例如,数据库 1 属于项目 1 ), 如图 14.1 所示。当一种资源类型以这种方式引用另一种资源时,我们说它们具有一对多关系。虽然这些类型的关系在设计 API 时通常很容易管理(通常只是引用其他资源的属性),但有时所需的关系更复杂,因此更难以以简单的方式表达使用API。
Most of the time, the relationships we’ll define between resources in an API will be simple and obvious because they tend to be unidirectional in nature, acting as pointers or references going from one resource to another (e.g., database 1 belongs to project 1), as shown in figure 14.1. When one resource type refers to another in this way, we say they have a one-to-many relationship. While these types of relationships are typically easy to manage when designing an API (often just a property referencing the other resource), there are other times the relationships needed are more complicated and, as a result, are more difficult to express in an easy-to-use API.
Figure 14.1 Unidirectional, or hierarchical, relationships between resources
这些更复杂的关系中最常见的一种类型是多对多关系,其中资源类型相互关联,而不是仅仅指向另一个。例如,让我们想象一个场景,我们需要跟踪人(用户)和他们所属的各个组(组)。这些用户可能是许多不同组的成员,显然,组由许多不同的用户组成。此外,可能有一些关于我们想要跟踪的关系本身的特定信息。例如,我们可能想要存储用户加入群组的确切时间或他们在群组中可能担任的特定角色。
One of the most common types of these more complicated relationships is a many-to-many relationship, where resource types are associated with one another rather than one merely pointing to another. For example, let’s imagine a scenario where we need to track people (users) and the various groups they belong to (groups). These users may be members of lots of different groups and, obviously, groups are made up of many different users as members. Additionally, there may be some specific information about the relationship itself we want to keep track of. For example, maybe we want to store the exact time a user joined a group or the specific role they might hold in the group.
虽然这些多对多关系在大多数关系数据库中都有标准的规范表示(依赖于连接表或链接表,其中包含负责跟踪两种类型实例之间关联的行),我们并不总是很清楚我们如何在 API 中公开这个概念。此模式的目标是概述一种特定的方式,我们可以通过这种方式将这些连接表中的行公开为单独的资源,这些资源表示 API 中两个资源之间的关联。
While these many-to-many relationships have a standard canonical representation in most relational databases (relying on a join table or link table, which contain rows responsible for keeping track of the associations between instances of the two types), it’s not always clear how we expose this concept in an API. The goal of this pattern is to outline a specific way in which we can expose the rows in these join tables as separate resources that represent the association between two resources in an API.
作为前面提到,存储多对多关系的最常见方法,特别是在关系数据库中,是使用一个连接表,其中的行表示两个资源之间的关系。例如,我们可能有一个User资源表, 另一个Group资源,然后是一个将这两个资源映射在一起的表(例如,UserGroup资源), 如图 14.2 所示。因此,通过 API 呈现这些资源及其关系的一种明显方法就是将所有表公开为资源。在这种情况下,数据库模式实际上与 API 表面一对一对齐,因此我们将定义一个由三种资源组成的 API:User、Group和UserGroup(映射或关联资源)。
As mentioned, the most common way of storing many-to-many relationships, particularly in relational databases, is by using a join table with rows representing the relationship between two resources. For example, we might have a table of User resources, another of Group resources, and then a table mapping these two resources together (e.g., UserGroup resources), as shown figure 14.2. As a result, one obvious way to present these resources and their relationship via an API is simply to expose all tables as resources. In this scenario, the database schema actually lines up one-to-one with the API surface, so we would define an API consisting of three resources: User, Group, and UserGroup (the mapping or association resource).
Figure 14.2 Using a join table to store many-to-many relationships
这就引出了一个明显的问题:这是如何工作的?在高层次上,我们可以依靠协会资源来完成大部分繁重的工作。由于关系由实际资源表示,因此它的创建和删除与其他任何资源一样。在此示例中,创建UserGroup资源代表某人加入群组,删除资源代表某人离开群组。此外,我们始终可以通过使用其标识符(例如,GetUserGroup),并且我们可以通过检索基于其父项(例如,ListUserGroups)的集合来列出所有关联。要仅查看特定关联(例如,属于单个组或单个用户的关联),我们可以在列出时应用过滤器。
This leads to the obvious question: how does this work? At a high level, we can rely on the association resource to do most of the heavy lifting. Since the relationship is represented by an actual resource, it is created and deleted like any other. In this example, creating a UserGroup resource represents someone joining a group and deleting one represents someone leaving a group. Additionally, we can always view a specific association by retrieving it using its identifier (e.g., GetUserGroup), and we can list all associations by retrieving the collection based on its parent (e.g., ListUserGroups). To see only specific associations (e.g., those belonging to a single group or a single user), we can apply a filter when listing.
最后,使用关联资源的最强大的方面是能够存储关于关系本身的元数据。例如,我们可以存储额外的信息,例如他们何时加入或他们可能具有的角色(例如,管理员),而不是简单地存储用户已加入特定组。UserGroup这是检索资源有意义的主要原因:我们可能想了解有关关系本身的更多细节。
Finally, the most powerful aspect of using an association resource is the ability to store metadata about the relationship itself. For example, rather than simply storing that a user has joined a specific group, we can store extra information such as when they joined or what role they might have (e.g., administrator). This is the primary reason retrieving a UserGroup resource makes sense: we might want to know more detail about the relationship itself.
有时关联资源具有一种或两种关联资源的所有权感。换句话说,将组视为包含用户列表似乎很自然。同样,将用户视为许多不同组的成员似乎很自然。此外,在这两种情况下,很自然地想问我们 API 的这两个问题,例如,“这个用户是哪个组的成员?” 和“这个组中有哪些用户?”
Sometimes the association resource has a sense of ownership by one or both of the associated resources. In other words, it seems natural to see a group as containing a list of users. Likewise, it seems natural to see a user as being a member of many different groups. Further, in both of these cases, it’s natural to want to ask both of those questions of our API, for example, “What groups is this user a member of?” and “What users are in this group?”
虽然我们已经注意到我们可以简单地请求列出具有适当过滤器的关联,但通常在面向资源的 API 中,消费者希望能够以更直接的方式思考关系,因此,有时这很有意义为这些类型的常见问题提供方便的别名。为了表达关联资源的这种自然对齐,我们可以选择在每个关联资源下为子集合起别名,这样我们就可以使用单个未过滤的请求来询问这些问题。稍后我们将对此进行更详细的探讨,但现在应该足以说明这里的策略是依靠别名来提供询问常见问题的便利多对多关系。
While we’ve noted that we can simply make a request to list associations with an appropriate filter, often in resource-oriented APIs, consumers want the ability to think of relationships in a more direct fashion and, as a result, it sometimes makes sense to provide convenient aliases for these types of common questions. To express this natural alignment of the association resources, we can optionally alias a subcollection underneath each of the resources being associated so that we can ask these questions using a single unfiltered request. We’ll explore this in more detail later, but for now it should suffice to say that the strategy here is to rely on aliases to provide the convenience of asking common questions about many-to-many relationships.
现在在我们理解了该模式的高级策略之后,让我们更仔细地了解它如何工作以及我们如何实现它的细节。让我们从头开始看命名。
Now that we understand the high-level strategy for this pattern, let’s look more closely at the details of how it works and how we can implement it. Let’s start at the beginning by looking at naming.
前我们可以做任何其他事情,我们首先必须为关联资源选择一个名称。在某些情况下,根据应用程序本身的上下文,这可能是显而易见的。例如,在允许学生注册课程的学校 API 中,关联资源可能称为CourseRegistration或CourseEnrollment。
Before we can do anything else, we first have to choose a name for the association resource. In some cases this may be obvious based on the context of the application itself. For example, in an API for a school that allows students to enroll in courses, the association resource might be called CourseRegistration or CourseEnrollment.
其他时候,这个名字可能更难找到。例如,在同一个 API 中,存储教师列表(和助理、实验室助理等)之间的关联在技术上不是注册或注册,因此我们一直在寻找替代方案。处理此问题的一个常见选择是使用诸如 Membership 或 Association 之类的东西和一些修饰符来阐明至少两个正在加入的资源之一(例如,CourseMembership)。在某些情况下,比如用户加入群组或俱乐部,我们可能只调用关联Membership而没有额外的修饰符,但这在上下文中应该总是有意义的。
Other times, the name might be harder to find. For example, in that same API, storing the association between the list of teachers (and assistants, lab assistants, etc.) isn’t technically an enrollment or a registration, so we’re stuck looking for an alternative. A common choice for handling this is to use something like Membership or Association with some modifiers to clarify at least one of the two resources being joined (e.g., CourseMembership). In some cases, like with users joining groups or clubs, we might just call the association Membership with no extra modifier, but this should always make sense in context.
一旦我们有了资源的名称,我们就可以开始研究如何实现与资源交互的各种方式它。
Once we have a name for the resource, we can start looking at how we implement the various ways to interact with it.
为了每个关联资源,我们至少需要实现一些标准方法。正如我们在表 14.1 中看到的,我们当然需要创建和删除标准方法,并且在关联资源保存元数据的常见场景中(即,当我们关心关于关系的额外信息时),我们需要获取并更新标准方法。最后,我们需要 list 方法来浏览各种成员资格。所有这些方法都应完全符合标准方法,考虑到成员资格与任何其他资源一样表示,这应该很容易。
For each association resource, we’ll need to implement at least some of the standard methods. As we can see in table 14.1, we’ll certainly need the create and delete standard methods, and in the common scenario where the association resource holds metadata (i.e., when we care about extra information about the relationship), we’ll need get and update standard methods. Finally, we’ll need the list method to browse through the various memberships. All of these methods should conform exactly to the standard methods, which should be easy given that memberships are represented like any other resource.
Table 14.1 Summary of standard methods on an association resource
在我们继续之前,重要的是要注意其他资源的标准方法实现中可能不存在的一项额外要求:唯一性约束。
Before we continue, it’s important to take note of one extra requirement that might not be present in other resources’ standard method implementations: uniqueness constraints.
不像大多数资源、关联资源往往有一个我们必须应用的非常重要的唯一性约束:应该只有一个关联资源代表正在关联的两个资源。换句话说,我们通常希望确定一个用户只是一个组的成员一次。为了强制执行此操作,在创建关联资源时对 API 提出了新的验证要求。
Unlike most resources, association resources tend to have a very important uniqueness constraint that we must apply: there should be only one association resource that represents the two resources being associated. In other words, we usually want to be certain that a user is only a member of a group once. To enforce this, there is a new validation requirement on the API when creating an association resource.
通常,我们只返回资源冲突错误(HTTP 代码409 Conflict) 当使用已经存在的标识符创建有问题的资源时。然而,在这种情况下,如果关联的两个资源不同,则关联资源仅在根本上与另一个资源不同。简单地说,清单 14.1 中的示例,我们尝试将一个用户添加到同一个组两次,应该会失败,因为它创建了一个冲突资源。
Typically, we only return a resource conflict error (HTTP code 409 Conflict) when the resource in question is created with an identifier that already exists. In this case, however, an association resource is only fundamentally distinct from another if the two resources being associated are different. Put simply, the example in listing 14.1, where we try to add a user to the same group twice, should fail as it’s creating a conflicting resource.
Listing 14.1 Associating the same data causing a conflict error
let membershipData = { userId: "Jimmy", groupId: "2" }; let membership1 = CreateMembership(membershipData); ❶ let membership2 = CreateMembership(membershipData); ❷
❶ First we add Jimmy to group 2. This should succeed.
❷ Jimmy 已经是第 2 组的成员,所以这个请求应该会失败并出现 409 HTTP 冲突错误。
❷ Jimmy is already a member of group 2, so this request should fail with a 409 Conflict HTTP error.
Next, let’s look at how updating association resources might be slightly different than we’ve seen in the past.
尽管关于关系的元数据可能会不时更改,关联的资源在资源的生命周期内不应更改。换句话说,当我们有一个资源代表一个用户加入一个组时,我们不应该改变它来使这个相同的资源代表一个不同的用户加入该组(或相同的用户加入一个不同的组)。相反,在那种情况下,我们应该简单地删除旧的关联资源并创建一个单独的资源来表示新的用户组关联。
While the metadata about a relationship might change from time to time, the resources being associated should not change in the lifetime of the resource. In other words, when we have a resource that represents a user joining a group, we should not be able to change this to make this same resource represent a different user joining the group (or the same user joining a different group). Instead, in that scenario we should simply delete the old association resource and create a separate one to represent the new user-group association.
这就引出了一个问题,即当消费者尝试进行此类更改时,它是否应该导致错误或静默失败。在这种情况下,两个交叉引用字段(用户和组字段)应被视为仅输出,因此在更新请求期间指定时将被忽略。
This leads to the question of whether it should result in an error or a silent failure when a consumer tries to make this type of change. In this case, the two cross-referencing fields (the user and group fields) should be considered output only and therefore ignored whenever specified during an update request.
Listing 14.2 Ignoring read-only fields at update time
let membership1 = CreateMembership({ ❶ userId: "Jimmy", groupId: "2", role: "admin" }); UpdateMembership({ ❷ id: membership1.id, userId: "Sally", role: "user" };
❶ Here, we add Jimmy to group 2 in an administrative role.
❷当我们尝试将用户更改为 Sally 并将角色更改为普通用户时,只会更新角色。此请求仅使 Jimmy 成为组 2 的普通用户,而对 Sally 没有任何作用。
❷ When we try to change the user to Sally and the role to be a regular user, only the role is updated. This request just makes Jimmy a regular user of group 2 and does nothing at all for Sally.
现在我们已经了解了标准方法以及它们应该如何与关联资源一起工作,让我们看看我们可能实现的一些可选的便捷方法来覆盖普通消费者场景。
Now that we’ve looked at the standard methods and how they should behave with an association resource, let’s look at some of the optional convenience methods we might implement to cover common consumer scenarios.
作为我们之前讨论过,我们可能想问的最常见问题之一是哪些资源与哪些其他资源相关联。例如,我们想快速询问“Jimmy 是哪个团体的成员?” 或“第 2 组中有哪些成员?” 虽然这完全有可能使用应用在ListMembershipsRequest,我们可以通过提供回答这两个特定问题的别名方法来简化这些常见查询。
As we discussed earlier, one of the most common questions we might want to ask is which resources are associated with which other resources. For example, we want to quickly ask, “What groups is Jimmy a member of?” or “What members are in group 2?” While this is perfectly possible using a specific filter applied on the ListMembershipsRequest, we can simplify these common queries by providing alias methods that answer these two specific questions.
Listing 14.3 Listing associations using a filter
memberships = ListMemberships({ filter: "groupId: 2" }); ❶ usersInGroup2 = memberships.map( ❷ (membership) => membership.user ); memberships = ListMemberships({ ❸ filter: "userId: Jimmy" }); jimmysGroups = memberships.map( ❹ (membership) => membership.groupId );
❶在这里,我们构建了一个 ListMembershipsRequest,它只返回第 2 组的成员。
❶ Here we construct a ListMembershipsRequest that only returns those for group 2.
❷要获得组 2 的成员用户,我们只需从每个成员中获取用户字段。
❷ To get the users who are members of group 2, we simply grab the user field from each membership.
❸这里我们构建了一个 ListMembershipsRequest,它只返回 Jimmy 作为用户的那些。
❸ Here we construct a ListMembershipsRequest that only returns those with Jimmy as the user.
❹要查看 Jimmy 所属的组,我们只需从每个成员中获取组字段即可。
❹ To see the groups Jimmy is a member of, we simply grab the group field from each membership.
虽然这确实有效,但制定专门针对这些用例的方法可能是有意义的。我们可以通过为我们想要执行的查询创建具有隐含(和必需)过滤器参数的别名方法来做到这一点。在User和资源通过关联资源Group加入的例子中Membership,我们可能有两个别名,如表 14.2 所示。
While this certainly works, it might make sense to make methods that focus specifically on these use cases. We can do this by creating alias methods that have an implied (and required) filter parameter for the query we want to do. In the example of User and Group resources joined by a Membership association resource, we might have two aliases, shown in table 14.2.
Table 14.2 Aliases for common queries of association resources
ListMemberships({ filter: "userId: Jimmy" }) |
ListUserGroups({ userId: "Jimmy" }) |
|
ListMemberships( { filter: "groupId: 2" }) |
ListGroupUsers({ groupId: 2 }) |
命名可能有点混乱,因此请记住名称的第一(单数)部分是拥有资源,第二(复数)部分是列出的资源。因此,在列出时,我们UserGroups正在查看给定用户的组,而在列出时GroupUsers,我们正在查看给定用户的用户团体。
The naming might be a bit confusing, so try to remember that the first (singular) portion of the name is the owning resource and the second (plural) portion is what’s being listed. So when listing UserGroups we’re looking at the groups for a given user, and when listing GroupUsers, we’re looking at the users for a given group.
作为我们在第 13 章中看到,API 中的引用完整性指的是当我们引用另一个资源时,我们需要考虑该引用是否有效。例如,如果我们删除一个仍在被其他资源指向的资源,会发生什么?更具体地说,如果我们有一个将两个资源绑定在一起的关联资源,我们如何处理删除关联资源之一的请求?
As we saw in chapter 13, referential integrity in an API refers to the fact that when we refer to another resource, we need to consider whether that reference is valid. For example, what happens if we delete a resource that is still being pointed to by other resources? More specifically, if we have an association resource that ties two resources together, how do we handle requests that would delete one of the resources being associated?
在这样的场景中,我们有几个不同的选项,由大多数现代关系数据库提供和标准化,并在表 14.3 中进行了总结。
In scenarios like this, we have a few different options, provided and standardized by most modern relational databases and summarized in table 14.3.
Table 14.3 Summary of referential integrity behavior
虽然选择这些选项的原因有很多,但在处理关联资源的情况下,API 通常应该选择限制删除,而其他资源仍指向该资源,或者什么都不做,并允许关联资源的引用变为无效. 如果我们改为选择级联或将指针设置为null,则有可能触发大量写入以将值设置为null(例如,有 100 亿个资源指向被删除的资源)或大量删除(例如,如果一个删除触发另一个,则触发另一个,然后继续)。简而言之,这意味着如果我们要尝试删除具有引用它的关联资源的资源,我们应该返回前提条件失败错误(HTTP 代码 412)或者简单地删除该资源并将关联资源保留为悬挂指针由稍后处理消费者。
While there are many reasons to choose each of these options, in the case of dealing with association resources the API should generally choose to either restrict deletion while other resources still point to the resource or simply do nothing and allow the association resource’s reference to become invalid. If we instead choose to cascade or set the pointer to null, there is a possibility that we could trigger a stampede of writes to set values to null (e.g., there are 10 billion resources that point to the resource being deleted) or an avalanche of deletions (e.g., if one deletion triggers another, which triggers another, and onward). In short, this means that if we were to try deleting a resource that had an association resource referring to it, we should either return a precondition failed error (HTTP code 412) or simply delete the resource and leave the association resource as a dangling pointer to be dealt with later by the consumer.
最后是时候查看一个完整的示例,该示例为 、 和 资源的关联资源User定义GroupAPI Membership。
Finally it’s time to look at a complete example of defining an API for an association resource of User, Group, and Membership resources.
Listing 14.4 Final API definition using association resources
abstract class GroupApi { static version = "v1"; static title = "Group API"; // ... Other methods left out for brevity. @post("/memberships") CreateMembership(req: CreateMembershipRequest): Membership; @get("/{id=memberships/*}") GetMembership(req: GetMembershipRequest): Membership; @patch("/{resource.id=memberships/*}") UpdateMembership(req: UpdateMembershipRequest): Membership; @delete("/{id=memberships/*}") DeleteMembership(req: DeleteMembershipRequest): void; @get("/memberships") ListMemberships(req: ListMembershipsRequest): ListMembershipsResponse; @get("/{groupId=groups/*}/users") ❶ ListGroupUsers(req: ListGroupUsersRequest): ListGroupUsersResponse; @get("/{userId=users/*}/groups") ❷ ListUserGroups(req: ListUserGroupsRequest): ListUserGroupsResponse; } interface Group { id: string; userCount: number; // ... ❸ } interface User { id: string; emailAddress: string; // ... ❹ } interface Membership { ❺ id: string; groupId: string; ❻ userId: string; role: string; ❼ expireTime: DateTime; } interface ListMembershipsRequest { parent: string; maxPageSize: number; pageToken: string; } interface ListMembershipsResponse { results: Membership[]; nextPageToken: string; } interface CreateMembershipRequest { resource: Membership; ❽ } interface GetMembershipRequest { id: string; } interface UpdateMembershipRequest { resource: Membership; fieldMask: FieldMask; } interface DeleteMembershipRequest { id: string; } // Optional from here on. interface ListUserGroupsRequest { userId: string; maxPageSize: number; pageToken: string; } interface ListUserGroupsResponse { results: Group[]; nextPageToken: string; } interface ListGroupUsersRequest { groupId: string; maxPageSize: number; pageToken: string; } interface ListGroupUsersResponse { results: User[]; nextPageToken: string; }
❶ Optional aliasing for listing users in a group
❷ Optional aliasing for listing the groups a user is a member of
❸请注意,我们不会在此处内联用户列表。要查看组的成员,我们只需为单个组列出具有过滤器的成员资格或依赖别名子集合 (ListGroupUsers)。
❸ Note that we do not in-line the list of users here. To view members of a group, we simply list memberships with a filter for a single group or rely on an aliased subcollection (ListGroupUsers).
❹请注意,我们不在此处内嵌组。要查看用户所属的组,我们只需为单个用户列出带有过滤器的成员资格或依赖别名子集合 (ListUserGroups)。
❹ Note that we do not in-line the groups here. To view the groups a user is a member of, we simply list memberships with a filter for a single user or rely on an aliased subcollection (ListUserGroups).
❺请注意,这里我们选择名称“Membership”作为用户与群组的关联资源。
❺ Note that we choose the name “Membership” here for the association resource of users to groups.
❻ Here we store references to the user and the group being associated, not an entire copy of the resources.
❼ The rest of the fields are extra metadata about the association.
❽注意这里没有父级,因为 Membership 资源是顶级资源。
❽ Note that there is no parent here as Membership resources are top-level resources.
现在我们已经了解了这三个选项中的每一个是如何工作的,让我们停下来看看我们在使用时丢失的一些东西这种模式。
Now that we’ve seen how each of these three options works, let’s pause to look at some of the things we lose out on when using this pattern.
当使用关联资源表示多对多关系时,您可以最自由地设计有关该关系如何工作的所有细节,但它也有一些缺点。
When using an association resource to represent a many-to-many relationship, you get the most freedom to design all the details about how that relationship works, but it comes with a few drawbacks.
在为了换取极高的灵活性,我们付出了界面稍微复杂一些的代价,有时可能感觉不直观。例如,当我们希望用户加入一个组时,我们使用标准的创建方法来建立新的成员资格,而不是调用一个JoinGroup方法。
In exchange for an extreme level of flexibility, we pay the price of a slightly more complicated interface that at times may not feel intuitive. For example, when we want a user to join a group, we use a standard create method to make a new membership rather than calling a JoinGroup method.
此外,我们还有一个额外的关联资源需要考虑,这意味着更大的 API 表面,因为 API 现在每个关联都有一个额外的资源和多达七个额外的方法需要考虑(五个用于标准方法和两个可选的别名方法)。通常这些方法很容易学习和理解,因此值得额外的认知负担;然而,值得指出的是,还有更多的事情需要学。
Additionally, we have an extra association resource to consider, which means a larger API surface as the API now has both one extra resource per association and up to seven additional methods to consider (five for the standard methods and two optional alias methods). Often these methods are easy to learn and understand and as a result are worth the additional cognitive load; however, it’s worth pointing out that there are simply more things to learn.
什么时候使用关联资源,即使我们可以提供别名方法来更轻松地询问有关资源关系的问题,我们仍然将两个资源之间的关联与每个资源的信息分开。例如,一个群组的描述和一个群组的成员列表都被认为是该群组的信息;然而,我们以两种截然不同的方式检索它们。获取组描述将使用一种GetGroup()方法,而查找该组的所有成员可能会使用ListGroupUsers()别名方法.
When using an association resource, even though we can provide alias methods to make it easier to ask questions about resource relationships, we still treat the association between the two resources as separate from the information about each resource. For example, the description of a group and the list of members of a group are both considered information about the group; however, we retrieve these in two very different ways. Getting the group description would be using a GetGroup() method, whereas finding all the members of the group might use the ListGroupUsers() alias method.
据我们了解,这是有充分理由的(用户列表可能会变得非常大),但这确实意味着尽管这两者与组的性质密切相关,但它们是分开的在题。
As we learned, there is a good reason for this (the list of users could get very large), but it does mean that there’s a separation of these two things despite both being closely related to the nature of the group in question.
Design an API for associating users with chat rooms that they may have joined and that also stores a role and the time when they join.
在聊天应用程序中,用户可能会多次离开和加入同一个房间。您将如何对 API 进行建模,以便在维护该历史记录的同时确保用户不能同时出现在同一个聊天室中时间?
In a chat application, users might leave and join the same room multiple times. How would you model the API such that you maintain that history while ensuring that a user can’t have multiple presences in the same chat room at the same time?
Many-to-many relationships are when two resources each have many of the other. For example, a user might be a member of many groups, and a group has many users as members.
We can use association resources as a way to model many-to-many relationships between resources in an API.
Association resources are the most flexible way of representing a relationship between two resources.
We can use these association resources to store additional metadata about the relationship between the two resources.
在本章中,我们将探索另一种模式来建模多对多关系,该模式依赖于自定义添加和删除方法来关联(和取消关联)两个资源。这种模式允许消费者管理多对多关系,而无需引入第三个关联资源作为必要的要求。
In this chapter, we’ll explore an alternative pattern for modeling many-to-many relationships that relies on custom add and remove methods to associate (and disassociate) two resources. This pattern allows consumers to manage many-to-many relationships without introducing a third association resource as a necessary requirement.
正如我们在第 14 章中了解到的,有时我们需要跟踪资源之间的关系,有时这些关系可能很复杂。特别是,我们经常不得不处理两个资源可以彼此有很多的情况,称为多对多关系。
As we learned in chapter 14, sometimes we have a need to keep track of the relationships between resources, and sometimes those relationships can be complicated. In particular, we often have to handle situations where two resources can both have many of one another, known as a many-to-many relationship.
虽然关联资源模式提供了一种非常灵活和有用的方式来对这种类型的关系进行建模,但似乎值得一问的是,如果我们可以忍受一些限制,是否可以有一种更简单的方式来做到这一点。换句话说,给定一些限制,我们能否让API更简单、更直观?如果是这样,具体的限制是什么?该模式探索了一种更简单的关联资源模式替代方案。
While the association resource pattern offers a very flexible and useful way to model this type of relationship, it seems worth asking whether there could be a simpler way to do this if we can live with some limitations. In other words, given some restrictions, can we make the API simpler and more intuitive? And if so, what are the specific limitations? This pattern explores a simpler alternative to the association resource pattern.
作为您可能会猜到,肯定有更简单的方法来表示和操作 API 中的多对多关系,但它们有一些限制。在这个特定的模式中,我们将研究一种隐藏表示这些关系的单个资源的方法,并使用自定义方法来创建和删除关联。让我们首先总结方法,然后探讨依赖此设计模式时出现的具体限制。
As you might guess, there certainly are simpler ways of representing and manipulating many-to-many relationships in an API, but they come with several restrictions. In this particular pattern, we’ll look at a way of hiding the individual resources that represent these relationships and use custom methods to create and delete associations. Let’s start by first summarizing the methods and then exploring the specific limitations that come up when relying on this design pattern.
在最基本的层面上,我们通过对消费者完全隐藏关联资源来简化 API,而是使用添加和删除自定义方法来管理关系。这些方法充当创建和删除两个相关资源之间的关联的快捷方式,并隐藏有关该关系的所有详细信息,除了它存在(或不存在)的事实。在用户可以是多个组(以及显然包含多个用户的组)成员的经典示例中,这意味着我们可以简单地使用这些方法来表示用户加入(添加)或离开(删除)给定组。
At the most basic level, we simplify the API by completely hiding the association resource from consumers and instead manage the relationship using add and remove custom methods. These methods act as shortcuts to create and delete associations between the two resources in question and hide all of the details about that relationship, except for the fact that it exists (or doesn’t exist). In the classic example of users who can be members of multiple groups (and groups that obviously contain multiple users), this means we could simply use these methods to represent users joining (add) or leaving (remove) a given group.
要采用这种模式,让我们看看需要考虑的一些限制。首先,由于我们只存储两个资源相关联的简单事实,我们将无法存储有关关系本身的任何元数据。这意味着,例如,如果我们使用此模式将用户作为组成员进行管理,我们将无法存储有关成员资格的详细信息,例如用户加入组的日期或用户在给定组中可能扮演的任何特定角色.
To adopt this pattern, let’s look at a few of the limitations we’ll need to take into consideration. First, since we only ever store the simple fact that two resources are associated, we won’t be able to store any metadata about the relationship itself. This means, for example, that if we use this pattern for managing users as members of groups, we can’t store details about the membership such as the date a user joined a group or any specific role a user might play in a given group.
接下来,由于我们要使用自定义方法来添加和删除资源之间的关联,因此我们必须考虑管理资源之一的资源——有点像一个人是另一个人的父母。更具体地说,我们必须选择是将用户添加到组还是将组添加到用户。如果是前者,那么用户资源就是被传递的资源,而组资源正在管理这种关系。如果是后者,则用户正在管理关系,而组则被传递。从实际代码(在这种情况下,以面向对象的编程风格)的角度考虑,管理资源是附加有添加和删除方法的资源。
Next, since we’re going to use custom methods to add and remove the association between the resources, we have to consider one of the resources the managing resource —sort of like one being the parent of the other. More concretely, we’ll have to choose whether we add users to groups or add groups to users. If the former, then the user resources are the ones being passed around and group resources are managing the relationship. If the latter, then users are managing the relationship while groups are passed around. Thinking in terms of actual code (in this case, in object-oriented programming style), the managing resource is the one that would have the add and remove methods attached to it.
Listing 15.1 Code snippets for two alternatives when choosing a managing resource
group.addUser(userId); ❶ user.addGroup(groupId); ❷
group.addUser(userId); ❶ user.addGroup(groupId); ❷
❶ When a group manages the relationship, we add users to a given group.
❷ When a user manages the relationship, we add groups to a given user.
有时管理资源的选择是显而易见的,但有时它可能更微妙,两种选择都有意义。在某些情况下,两者都不像管理资源那样直观,但归根结底,使用这种模式需要我们选择一个单一的管理资源。假设我们可以忍受这两种限制,让我们看看这个特定模式必须如何处理的细节工作。
Sometimes the choice of a managing resource will be obvious, but other times it might be more subtle and both options make sense. In some cases, neither will seem intuitive as the managing resource, but at the end of the day using this pattern requires that we choose a single managing resource. Assuming we can live with both of these limitations, let’s look at the specifics of how this particular pattern must work.
一次我们已经确定了管理资源,我们可以定义添加和删除自定义方法。方法的全名应遵循Add<Managing-Resource><Associated-Resource>(对于删除也是如此)的形式。例如,如果我们有可以从组中添加和删除的用户,则用户将是关联的资源和管理资源的组。这意味着方法名称将是AddGroupUser和RemoveGroupUser.
Once we’ve identified the managing resource, we can define add and remove custom methods. The full name of the method should follow the form of Add<Managing-Resource><Associated-Resource> (and likewise for remove). For example, if we have users who can be added and removed from groups, the user would be the associated resource and the group managing the resource. This means the method names would be AddGroupUser and RemoveGroupUser.
这些方法应接受包含父资源(在本例中为管理资源)和要添加或删除的资源标识符的请求。请注意,我们仅使用标识符而不是完整资源。这是因为如果消费者被引导相信他们有能力关联两个资源并同时更新其中一个,则其他信息将是无关紧要的并且可能具有误导性。表 15.1 中显示了添加和删除方法及其 HTTP 等价物的摘要。
These methods should accept a request that contains both a parent resource (in this case, the managing resource) and the identifier of the resource being added or removed. Notice that we use the identifier only and not the full resource. This is because the other information would be extraneous and potentially misleading if consumers were led to believe that they had the ability to associate two resources and update one of them at the same time. A summary of the add and remove methods and their HTTP equivalents are shown in table 15.1.
Figure 15.1 Add and remove method summary
到列出各种关联我们将依赖定制的列表标准方法,这些方法看起来就像我们在关联资源方法中讨论的别名方法。这些方法简单地列出了各种相关资源,提供了对结果的分页和过滤。由于我们有两种查看关系的方法(例如,我们可能希望查看哪些用户是给定组的成员以及给定用户是哪些组的成员),我们需要两种不同的方法的场景。
To list the various associations we will rely on customized list standard methods that look just like the alias methods we discussed in the association resource method. These methods simply list the various associated resources, providing pagination and filtering over the results. Since we have two ways to look at the relationship (for example, we might want to see which users are members of a given group as well as which groups a given user is a member of), we’ll need two different methods for each of the scenarios.
这些方法遵循与 add 和 remove 方法类似的命名约定,方法名称中包含两种资源。使用我们的用户和组示例,我们提供了两种不同的方法来列出给定特定条件的各种用户和组:ListGroupUsers提供属于给定组的用户列表和ListUserGroups提供给定用户所属的组列表。就像其他自定义方法一样,它们遵循类似的 HTTP 映射命名约定,但依赖于隐式子集合,总结在表 15。2.
These methods follow a similar naming convention to the add and remove methods, with both resources in the name of the method. Using our users and groups example, we provide two different methods to list the various users and groups given a specific condition: ListGroupUsers provides the list of users belonging to a given group and ListUserGroups provides the list of groups that a given user is a member of. Just like other custom methods, these follow a similar HTTP mapping naming convention but rely on an implicit subcollection, summarized in table 15.2.
Figure 15.2 List method summary
ListGroupUsers({ parent: "groups/1" }) |
GET /groups/1/users |
|
ListUserGroups({ parent: "users/1" }); |
GET /users/1/groups |
一当我们遇到重复数据问题时,就会出现常见问题。例如,如果我们尝试将同一用户添加到组中两次,会怎样?另一方面,如果我们试图从他们当前不属于的组中删除用户怎么办?
One common question arises when we run into issues of duplicate data. For example, what if we try to add the same user to a group twice? On the other hand, what if we attempt to remove a user from a group they aren’t currently a member of?
正如我们在第 7 章中看到的,这些情况下的行为将非常类似于删除不存在的资源并创建重复(因此冲突)的资源。这意味着如果我们尝试将用户添加到同一组两次,我们的 API 应该以冲突错误响应(例如,409 Conflict) 如果我们试图从一个不存在的组中删除一个用户,我们应该返回一个错误来表示一个失败的假设(例如,412 Precondition Failed) 表示我们无法执行请求的操作。
As we saw in chapter 7, the behavior in these cases is going to be very similar to deleting a resource that doesn’t exist and creating a duplicate (and therefore conflicting) resource. This means that if we attempt to add a user twice to the same group, our API should respond with a conflict error (e.g., 409 Conflict) and if we attempt to remove a user from a group that doesn’t exist, we should return an error expressing a failed assumption (e.g., 412 Precondition Failed) to signal that we’re not able to execute the requested operation.
这意味着如果消费者关心的只是确保用户是(或不是)给定组的成员,他们可以简单地将这些错误情况视为他们的工作已经完成。换句话说,如果我们只是想确定 Jimmy 是第 2 组的成员,那么成功的结果或冲突错误都是有效结果,因为两者都表示 Jimmy 当前是我们预期的组的成员。响应代码的不同仅表示我们是否负责添加到组中,但要么表示用户现在是组的成员团体。
This means that if all a consumer cares about is making sure a user is (or isn’t) a member of a given group, they can simply treat these error conditions as their work having already been done. In other words, if we just want to be sure that Jimmy is a member of group 2, a successful result or a conflict error are both valid results as both signify that Jimmy is currently a member of the group as we intended. The difference in response code simply conveys whether we were the ones responsible for the addition to the group, but either conveys that the user is now a member of the group.
一种清单 15.2 显示了此模式的完整示例,使用相同的用户和组示例。如您所见,这比依赖关联资源更短、更简单;然而,我们缺乏存储关于用户归属关系的元数据的能力到团体。
A full example of this pattern implemented, using the same users and groups example, is shown in listing 15.2. As you can see, this is much shorter and simpler than relying on an association resource; however, we lack the ability to store metadata about the relationship of users belonging to groups.
Listing 15.2 Final API definition using the add/remove pattern
abstract class GroupApi { static version = "v1"; static title = "Group API"; @get("{id=users/*}") GetUser(req: GetUserRequest): User; // ... ❶ @get("{id=groups/*}") GetGroup(req: GetGroupRequest): Group; // ... ❶ @post("{parent=groups/*}/users:add") AddGroupUser(req: AddGroupUserRequest): void; ❷ @post("{parent=group/*}/users:remove") RemoveGroupUser(req: RemoveGroupUserRequest): void; ❷ @get("{parent=groups/*}/users") ListGroupUsers(req: ListGroupUsersRequest): ❸ ListGroupUsersResponse; @get("{parent=users/*}/groups") ListUserGroups(req: ListUserGroupsRequest): ❹ ListUserGroupsResponse; } interface Group { id: string; userCount: number; // ... ❺ } interface User { id: string; emailAddress: string; } interface ListUserGroupsRequest { parent: string; maxPageSize?: number; pageToken?: string; filter?: string; } interface ListUserGroupsResponse { results: Group[]; nextPageToken: string; } interface ListGroupUsersRequest { parent: string; maxPageSize?: number; pageToken?: string; filter?: string } interface ListGroupUsersResponse { results: User[]; nextPageToken: string; } interface AddGroupUserRequest { parent: string; userId: string; } interface RemoveGroupUserRequest { parent: string; userId: string; }
❶为简洁起见,我们将省略 User 和 Group 资源的所有其他标准方法。
❶ For brevity we’re going to omit all of the other standard methods for the User and Group resources.
❷我们使用 AddGroupUser 和 RemoveGroupUser 来操作哪些用户与哪些组相关联。
❷ We use AddGroupUser and RemoveGroupUser to manipulate which users are associated with which groups.
❸要查看给定组中有哪些用户,我们使用隐式子集合映射到 ListGroupUsers 方法。
❸ To see which users are in a given group, we use an implicit subcollection mapping to a ListGroupUsers method.
❹要查看用户属于哪些组,我们使用相同的想法并创建一个 ListUserGroups 方法。
❹ To see which groups a user is a member of, we use the same idea and create a ListUserGroups method.
❺请注意,我们不会在此处内联用户,因为列表可能会很长。要查看用户列表,我们改为使用 ListGroupUsers。
❺ Note that we do not in-line the users here since the list could be very long. To see the list of users we instead use ListGroupUsers.
作为我们在本章开头提到过,此模式的主要目标是提供管理多对多关系的能力,而无需承担成熟关联资源的复杂性。作为这种简化的交换,我们以功能限制的形式进行了一些权衡。
As we noted at the start of this chapter, the primary goal of this pattern is to provide the ability to manage many-to-many relationships without taking on the complexity of a full-blown association resource. In exchange for this simplification, we make a few trade-offs in the form of functional limitations.
不像关联资源,使用添加和删除自定义方法要求我们选择一个资源作为管理资源,另一个作为托管资源。换句话说,我们需要决定将哪个资源添加(和删除)到另一个资源(以及从另一个资源中移除)。在许多情况下,这种非互易性是方便和明显的,但其他时候这似乎违反直觉的。
Unlike an association resource, using add and remove custom methods requires that we choose one of the resources to be the managing resource and another to be the managed resource. In other words, we need to decide which resource is the one being added (and removed) to (and from) the other. In many cases this nonreciprocity is convenient and obvious, but other times this can seem counterintuitive.
经过使用自定义添加和删除方法而不是关联资源,我们放弃了存储有关关系本身的元数据的能力。这意味着除了关系的存在(或不存在)之外,我们将无法存储任何信息。换句话说,我们无法跟踪关系的创建时间或关系的任何特定内容,而是必须将那别处。
By using custom add and remove methods rather than an association resource, we give up the ability to store metadata about the relationship itself. This means that we won’t be able to store any information other than the existence (or lack of existence) of a relationship. In other words, we can’t keep track of when the relationship was created or anything specific to the relationship and instead will have to put that elsewhere.
When would you opt to use custom add and remove methods rather than an association resource to model a many-to-many relationship between two resources?
When associating Recipe resources with Ingredient resources, which is the managing resource and which the associated resource?
What would the method be called to list the Ingredient resources that make up a specific recipe?
When a duplicate resource is added using the custom add method, what should the result be?
对于关联资源过于重量级的场景(例如,不需要任何额外的面向关系的元数据),使用添加和删除自定义方法可能是管理多对多关系的更简单方法。
For scenarios where association resources are too heavyweight (e.g., there’s no need for any extra relationship-oriented metadata), using add and remove custom methods can be a simpler way to manage many-to-many relationships.
Add and remove custom methods allow an associated resource (i.e., the subordinate) to be added or removed in some association to a managing resource.
Listing associated resources can be performed using standard list methods on the meta-resources.
在构建软件系统时,多态性为对象提供了采用多种不同形式的能力,通常依赖于显式对象继承。在此模式中,我们将探索如何将这个强大的工具从面向对象编程的世界转化为面向资源的 API 设计世界。我们还将研究一些指南,了解何时依赖多态资源而不是完全独立的资源。
When building software systems, polymorphism provides the ability for objects to take many different forms, typically relying on explicit object inheritance. In this pattern, we’ll explore how to translate this powerful tool from the world of object-oriented programming to that of resource-oriented API design. We’ll also investigate a few guidelines for when to rely on polymorphic resources over completely independent resources.
在面向对象编程 (OOP) 中,多态性是跨不同具体类型使用单一公共接口的想法,最大限度地减少我们需要了解的实现细节,以便与特定类型进行交互。换句话说,如果我们有class Triangle和class Square,他们可能都实现了一个共同的countSides()方法并通过扩展共享来指定它interface Shape.
In object-oriented programming (OOP), polymorphism is the idea of using single common interfaces across different concrete types, minimizing the implementation details we need to understand in order to interact with a specific type. In other words, if we have class Triangle and class Square, they might both implement a common countSides() method and specify this by extending a shared interface Shape.
Listing 16.1 Example of polymorphism in TypeScript
interface Shape { countSides(): number; ❶ } class Triangle implements Shape { ❷ countSides() { return 3; } } class Square implements Shape { ❷ countSides() { return 4; } }
❶ The interface declares a single function that must be implemented.
❷ Two different classes implement the interface and therefore implement the method declared.
由于我们有这个公共接口,我们可以编写只处理Shape接口的代码,这意味着我们不必关心特定形状是 aTriangle还是 a Square(或其他),只要它实现了Shape接口指定的要求即可。
Since we have this common interface, we can write code that deals only with Shape interfaces, which means that we wouldn’t have to care whether the specific shape is a Triangle or a Square (or something else) so long as it implements the requirements specified by the Shape interface.
Listing 16.2 Polymorphic method to count sides on any shape
function countSides(shape: Shape): number { ❶ return shape.countSides(); }
❶我们不必指定三角形或正方形作为输入。我们可以只要求该类实现 Shape 接口。
❶ We don’t have to specify a Triangle or a Square as input. We can just require that the class implement the Shape interface.
这个想法在 OOP 中非常强大,导致更简洁和更模块化的代码,所以我们可能希望在 Web API 中具有相同的功能才有意义。当我们试图弄清楚如何将这个概念从面向对象编程语言的世界准确地转换为 JSON 和 HTTP 的世界时,问题就出现了。毕竟,countSides()方法的等价物是什么在面向资源的 Web API 中?代表我们想要的东西的正确资源是什么?此模式的目标是说明一种安全、灵活且可持续的方式,将面向对象编程的最强大特性之一带入面向资源的 API 世界。
This idea is very powerful in OOP, leading to much cleaner and more modular code, so it only makes sense that we might desire this same ability in a web API. The problem arises when we try to figure out how to accurately translate this concept from a world of object-oriented programming languages to a world of JSON and HTTP. After all, what is the equivalent of the countSides() method in a resource-oriented web API? What is the right resource to represent what we want? The goal of this pattern is to illustrate a safe, flexible, and sustainable way to bring one of the most powerful features of object-oriented programming into the world of resource-oriented APIs.
在在这个模式中,我们将探索多态资源的概念。这些资源采用通用接口(例如,Shape)的形式,带有一个明确的字段,指定更详细的资源类型(例如,三角形或正方形)。虽然依赖这种策略有很多好处,但最重要的是我们不必为每个特定的实现重复标准方法。换句话说,除了ListTrianglesand ListSquares,我们可以使用一个ListShapes方法.
In this pattern, we’ll explore the concept of polymorphic resources. These are resources that take the form of the generic interface (e.g., Shape) with an explicit field specifying the more detailed type of the resource (e.g., triangle or square). While there are many benefits of relying on this strategy, the most important one is that we won’t have to duplicate standard methods for each specific implementation. In other words, rather than ListTriangles and ListSquares, we can use a single ListShapes method.
虽然此策略简化了多态行为(例如,与通用资源而不是特定实现交互的方法),但它没有说明多态的数据存储方面。换句话说,正方形有边长,三角形有底边和高,这意味着要真正表示这些不同形状的细节,我们实际上需要在资源上存储不同的字段。为了解决这个问题,我们最终将不得不采用一些服务器端验证,这样我们的通用接口就是必要字段的超集,因此能够存储与所有各种子类型对应的字段。换句话说,Shape资源可能有办法存储半径(对于圆形)、底和高度(对于三角形)和长度(对于正方形),并根据类型规范验证这些值。
While this strategy simplifies the polymorphic behavior (e.g., methods that interact with the generic resource rather than the specific implementation), it says nothing about the data storage aspects of polymorphism. In other words, while a square has a side length, a triangle has a base and a height, meaning that to truly represent the details of these different shapes, we actually need to store different fields on the resource. To address this, we’ll ultimately have to employ some server-side validation, such that our generic interface is a superset of the fields necessary and therefore capable of storing fields corresponding to all the various subtypes. In other words, the Shape resource might have a way to store both a radius (for circles), base and height (for triangles), and length (for squares) and to validate these values depending on the type specification.
然而,与往常一样,细节使事情变得复杂。例如,如果 API 请求包含适用于错误类型资源的字段(例如,Shape带有类型正方形的半径定义),我们应该怎么办?这应该是一个错误,还是应该被忽略?在下一节中,我们将探讨所有这些在更多方面是如何工作的细节。
As always, however, the details are where things get complicated. For example, what should we do if an API request includes fields that apply to the wrong type of resource (e.g., a radius definition for a Shape with type square)? Should that be an error, or should it be ignored? In the next section, we’ll explore how all of this works in much more detail.
多态性在许多编程语言中显然是一个非常强大的工具,因此多态资源对于 Web APIs 来说同样非常有价值是理所当然的。为了充分利用这个概念,我们需要了解这些资源在行为层面和结构层面上的工作原理。但在我们这样做之前,我们首先需要了解什么时候依赖多态性是有意义的。
Polymorphism is obviously a very powerful tool in many programming languages, so it stands to reason that polymorphic resources are similarly quite valuable for web APIs. To get the most out of this concept, we’ll need to understand how these resources work, on both a behavioral level and a structural level. But before we do that, we need to understand when it makes sense to rely on polymorphism in the first place.
经常在设计 API 时,我们会发现两个建议资源之间的共性,这让我们三思而后行,认为它们是两个独立的资源。例如,在聊天 API 中,我们可能会考虑一个TextMessage资源也PhotoMessage,VideoMessage, 或AudioMessage资源. 它们中的每一个都有一些共同点(例如,它们都代表一条通过聊天 API 发送的消息),但它们之间又略有不同(在本例中,就是消息的内容)。
Often when designing an API we’ll find commonality between two proposed resources that makes us think twice about these being two separate resources. For example, in a chat API, we might consider a TextMessage resource as well as PhotoMessage, VideoMessage, or AudioMessage resources. Each of these has something in common (e.g., they all represent a message being sent over the chat API), but they all have something slightly different from one another (in this case, that’s the content of the message).
如果我们在本地编程时遇到同样的场景,我们可能会考虑一个Message接口以及实现该接口的其他几个类,其目标是编写仅处理通用Message接口类型的值而不是该接口的特定实现的代码。但是,这种相同的逻辑是否适用于 Web API?换句话说,我们是否应该使用相同的普遍性论证来确定是否使用单一Message资源而不是为每种消息类型提供几个独立的资源?
If we were to run into this same scenario when programming locally, we might consider a Message interface and several other classes that implement that interface, with the goal being to write code that deals only with values of the generic Message interface type rather than specific implementations of that interface. But does this same logic carry over to a web API? Put differently, should we use the same argument of generality to determine whether or not to use a single Message resource rather than several independent resources for each message type?
虽然直觉可能是正确的,但逻辑并不完全正确。更具体地说,我们在使用本地编程语言时关心的是通用性和编写支持未来扩展和代码重用等功能的功能。在我们的 Web API 中,我们应该更多地关注我们打算如何使用各种标准方法,以及这些标准方法对所有这些资源的操作是否相同,或者对于不同类型有一些行为差异。
While the intuition is likely correct, the logic doesn’t exactly translate. To be more specific, our concern when working in a local programming language is about generality and writing functions that support things like future expansion and code reuse. In our web API, our concern should be more about how we intend the various standard methods to work and whether those standard methods operate identically on all of these resources or have some behavioral differences for the different types.
例如,某些东西应该依赖多态资源(例如,Message)而不是独立资源(例如,VideoMessage)的一个很好的指标是考虑将所有不同类型一起列出是否有意义。换句话说,我们是想调用一个方法并在一个响应中检索所有资源类型,还是只需要按不同的子类型列出资源?对于聊天 API,答案很明显:我们几乎肯定想要列出ChatRoom资源中的所有消息,无论其类型如何。否则,我们将很难检索到正确的消息列表,需要我们进行多次 API 调用(然后按创建时间交错结果)才能正确显示聊天内容。
For example, a great indicator that something should rely on a polymorphic resource (e.g., Message) rather than independent resources (e.g., VideoMessage) is to consider whether it makes sense to list all the different types together. In other words, do we want to call a single method and retrieve all of the resource types in a single response, or do we only ever need to list resources by their distinct subtypes? In the case of the chat API, the answer is pretty obvious: we almost certainly want to list all messages in a ChatRoom resource, regardless of their type. Otherwise, we’d have a pretty tough time retrieving a proper list of messages, requiring us to make several API calls (and then interleaving the results by their creation time) in order to display the chat content appropriately.
考虑一个不同的场景,其中 API 可能有ChatRoom资源和广播组,中央机构可以向所有用户发送单向更新。虽然这种“广播”概念在本质上与ChatRoom资源相似,因为它们都包含消息,但我们与它们交互的方式却大不相同。成员资格是不确定的,它们只能由 API 的管理员创建,消息只能在一个方向上发送,列表还在继续。因此,即使这个概念可能被压缩到一个多态ChatRoom资源中(具有用于广播的特殊类型),如果我们要创建一个单独的Broadcast资源,访问模式更有可能更容易管理那,虽然类似于 a ChatRoom,但有根本的不同并且是独立对待的。
Consider a different scenario where an API might have ChatRoom resources as well as broadcast groups, where a central authority can send out unidirectional updates to all users. While this “broadcast” concept is similar in nature to a ChatRoom resource in that they both contain messages, the way we interact with them differs enormously. The membership is indeterminate, they can only be created by administrators of the API, the messages are only ever sent in one direction, the list goes on. As a result, even though this concept could potentially be squeezed into a polymorphic ChatRoom resource (with a special type for broadcast), it’s far more likely that the access patterns will be easier to manage if we were to create a separate Broadcast resource that, while similar to a ChatRoom, is fundamentally different and treated independently.
现在我们已经了解了如何在独立资源和多态资源之间做出决定,让我们探讨如何构建这些特殊资源资源。
Now that we have some indication about how to decide between independent resources and polymorphic resources, let’s explore how to structure these special resources.
多态的资源有一个重要的区分字段:类型。由于多态资源本身类似于我们编程语言中的通用接口,因此我们需要一种明确的方式来表明此通用接口的实例是特定的子类型。例如,对于我们的Message界面,我们需要一种方法来指定消息是视频消息、音频消息等。但是这个类型字段应该是什么样的呢?
Polymorphic resources have one important distinguishing field: a type. Since the polymorphic resource itself is akin to the generic interface in our programming language, we need an explicit way of communicating that the instance of this generic interface is a specific subtype. For example, with our Message interface, we need a way of specifying whether the message is a video message, audio message, and so on. But what should this type field look like?
尽管依赖枚举可能很诱人,回想一下第 5 章,枚举往往会带来许多问题,并且有一套非常具体的使用标准。相反,我们可以使用一个简单的字符串字段,并对可以存储在该字符串字段中的值进行一些验证。
While it might be tempting to rely on an enumeration, recall from chapter 5 that enumerations tend to come with many issues and have a very specific set of criteria for their use. Instead, we can use a simple string field with some validation about the values that can be stored in this string field.
Listing 16.3 Example of polymorphic resource with type field
interface Message { id: string; sender: string; type: 'text' | 'photo' | 'audio' | 'video'; ❶ // ... }
❶ The type field is just a simple string storing the allowed subtypes.
由于这个字段是一个简单的字符串,我们现在可以像使用其他字段一样使用它。例如,如果我们想列出所有的消息,但只列出照片,我们可以ListMessages()调用一个应用到该type字段的过滤器,我们将在第 22 章中看到。这也意味着创建一个新Message资源可以采用任何相关形式,将适当的值传递到CreateMessage()调用中.
Since this field is a simple string, we can now use it like we would any other. For example, if we wanted to list all the messages but only the photos, we can make a ListMessages() call with a filter applied to the type field, as we’ll see in chapter 22. This also means that creating a new Message resource can take any of the relevant forms, with the proper values passed into a CreateMessage() call.
尽管这与任何其他字段一样,但我们确实有一个重要问题需要解决:该字段应该是永久性的还是可以根据现有资源进行更改?换句话说,我们能否将资源从一种类型(例如,视频消息)转变为另一种类型(例如,文本消息)?虽然从技术上讲,资源上没有任何内容直接阻止它按预期工作,但通常不鼓励这样做。原因是随着 API 变得越来越复杂,不同资源之间的关系越来越多,改变多态资源的类型可能最终会打破现有资源所做的假设,而现有资源恰好引用了多态资源。当我们像这样改变类型和打破假设时,通常会导致用户感到困惑,并导致 API 本身出现错误。因此,
Despite this being just like any other field, we do have an important question to address: should this field be permanent or can it be changed on an existing resource? In other words, can we morph a resource from one type (e.g., a video message) to another type (e.g., a text message)? While there’s technically nothing directly on the resource preventing this from working as expected, it’s generally discouraged. The reason is that as APIs get more complex and there are more relationships between different resources, changing the type of a polymorphic resource might end up breaking an assumption made by an existing resource that happens to reference the polymorphic one. When we go around changing types and breaking assumptions like that, it often leads to confusing situations for users and bugs in the API itself. As a result, this should be avoided if at all possible.
这种多态资源数据的想法将我们引向下一个问题:我们究竟如何存储消息的内容?毕竟,对于每一种不同的消息类型,都有截然不同的内容存储。
This idea of polymorphic resource data leads us to the next problem: how exactly do we store the content of the message? After all, for each different message type there’s vastly different content being stored.
这多态资源的主要好处是,根据定义,它可以采用多种不同的形式。虽然这通常植根于资源的行为(例如,当我们在不同类型的多态资源上调用相同的 API 方法时会发生不同的事情),但这些资源很少会具有完全相同的结构。相反,几乎可以保证不同的类型需要存储不同的信息。
The primary benefit of a polymorphic resource is that, by definition, it’s able to take on many different forms. And while this is often rooted in the behavior of the resource (e.g., different things happen when we call the same API method on different types of a polymorphic resource), it’s very rare that these resources will have the exact same structure. On the contrary, it’s almost a guarantee that different types will need to store different information.
在形状的例子中,我们需要存储圆的半径和正方形的长度。对于我们Message在聊天中的资源,短信可以只存储文本内容;然而,照片消息需要存储某种图像,这显然是完全不同的。那么我们该怎么做呢?
In the example of shapes, we need to store a radius for circles but a length for squares. For our Message resource in a chat, text messages can just store the text content; however, photo messages need to store an image of some sort, which is obviously quite different. So how do we go about this?
处理这个问题的最简单方法(恰好是更严格的接口定义语言首选的方法)是让资源充当每个单独类型的所有字段的超集。换句话说,Message资源可能有一个用于存储文本内容的字段,另一个用于存储照片内容,另一个用于存储视频内容,等等。
The simplest method for handling this (and just so happens to be the method preferred with stricter interface definition languages) is to have the resource act as a superset of all the fields for each individual type. In other words, a Message resource might have a field for storing text content, another for storing photo content, another for video content, and so on.
Listing 16.4 Storing data with a different field for each attribute
interface Message { id: string; sender: string; type: 'text' | 'photo' | 'audio' | 'video'; text?: string; ❶ photoUri?: string; videoUri?: string; audioUri?: string; }
❶ The resource acts a superset by defining all fields that are relevant.
有时这种超集排列是必需的,但在许多接口定义语言中(例如我们在本书中使用的 TypeScript 语言),我们实际上可以重用一个字段来表示这些字段中的每一个。换句话说,而不是有一个领域text,videoUri等等,我们可以有一个单独的内容字段,根据type字段的不同,它具有不同的含义。
Sometimes this type of superset arrangement is a requirement, but in many interface definition languages (such as the TypeScript one we’re using throughout this book), we can actually reuse a single field to represent each of these fields. In other words, rather than having a field for text, videoUri, and so on, we can have a single field for content, which takes on a different meaning depending on the type field.
Listing 16.5 Single fields storing data for multiple fields
interface Message { id: string; sender: string; type: 'text' | 'photo' | 'audio' | 'video'; content: string; ❶ }
❶该字段现在表示文本内容或媒体 URI,但取决于 Message 资源的类型。
❶ This field now represents the text content or media URIs, but depends on the type of the Message resource.
到目前为止,在这个示例中,每种不同的内容类型都表示为一个简单的字符串,但如果还有更多呢?例如,如果我们需要跟踪媒体内容类型(例如,视频的编码格式)以及视频文件的位置怎么办?在这种情况下,我们可以为媒体内容定义一个单独的接口,同时仍然为基于文本的内容使用一个简单的字符串值。
In this example so far, each of the different content types is represented as a simple string, but what if there’s more to this? For example, what if we needed to keep track of the media content type (e.g., the encoding format for a video) as well as the location of the video file? In this case, we might define a separate interface for the media content, while still using a simple string value for the text-based content.
Listing 16.6 Fields with different types depending on the message type
interface Message { id: string; sender: string; type: 'text' | 'photo' | 'audio' | 'video'; content: string | Media; ❶ } interface Media { ❷ contentUri: string; contentType: string; }
❶ In this case, the content can take on multiple different types.
❷我们可以将媒体定义为对包含实际内容和内容类型的 URI 的引用,例如视频/mp4。
❷ We might define Media as a reference to a URI holding the actual content as well as a content type, such as video/mp4.
在所有这些示例中,一般准则是我们应该尝试仅抽象到结构继续具有实际意义的程度。content在消息的情况下,我们在文本和媒体的一般概念之间进行抽象没有问题,检查资源类型然后解释字段仍然很有意义适当地。
In all of these examples, the general guideline is that we should attempt to abstract only to the point where the structure continues to make practical sense. In the case of messages, we have no issue abstracting between text and a generic idea of media, and it still makes perfect sense to check the type of the resource and then interpret the content field appropriately.
但是,在Shape资源的情况下不同的类型,尽管大多数字段都会是数字类型,但字段的名称实际上非常重要。结果,我们可能有一个dimension字段它包含各种维度,但具有多种类型来存储每个形状的不同维度信息。
However, in the case of a Shape resource with different types, despite the fact that most fields will all be number types, the names of the fields are actually quite important. As a result, we might have a dimension field that holds the various dimensions, but have a variety of types to store the different dimension information for each shape.
Listing 16.7 Storing different field names for different shapes
interface Shape { id: string; type: 'square' | 'circle' | 'triangle'; dimension: SquareDimension | CircleDimension | ➥ TriangleDimension; ❶ } interface SquareDimension { length: number; ❷ } interface CircleDimension { radius: number; ❷ } interface TriangleDimension { base: number; ❷ height: number; }
❶ A dimension field stores the various ways of defining different shapes.
❷每个形状的维度都有不同数量的字段,每个字段都有不同的名称。
❷ Each shape’s dimension has a different numbers of fields, each with different names.
现在我们已经介绍了结构,花点时间考虑一下我们如何与该结构交互可能是有意义的。更重要的是,当我们试图违反规则时会发生什么,以便说话?
Now that we’ve covered the structure, it might make sense to take a moment to consider how we might interact with that structure. More importantly, what should happen when we attempt to bend the rules, so to speak?
作为我们在上一主题中看到,不同类型的多态资源可能存储不同的信息。此外,虽然有时可以使用完全相同的字段并根据类型对其进行不同的解释,但有时我们需要为需要存储的不同信息指定不同类型的不同字段。当创建或更新多态资源时提供无效数据时,这两种情况都会导致更多的复杂性。
As we’ve seen in the last topic, different types of a polymorphic resource might store different information. Further, while sometimes it’s possible to use the exact same field and interpret it differently depending on the type, other times we need to specify different fields with different types for the divergent information that needs to be stored. Both of these scenarios lead to more complications when invalid data is provided when creating or updating a polymorphic resource.
换句话说,正如字段可能会根据type资源的字段进行不同的解释一样,这也意味着需要以不同的方式应用验证规则。例如,如果我们依赖一个Message带有content: string字段的简单资源,如果类型是 ,该字段可能存储任意文本内容text,但如果类型是媒体类型,则它应该是一个 URI,例如photo. 因此,如果有人指定将一条消息视为照片消息,那么如果content字段不是有效的 URI。
Put another way, just as fields might be interpreted differently depending on the type field of the resource, this also means that validation rules will need to be applied differently. For example, if we rely on a simple Message resource with a content: string field, this field might store arbitrary textual content if the type is text, but it should be a URI if the type is a media type, such as a photo. As a result, if someone specifies that a message is to be treated as a photo message, it’s critical that we return an error if the content field isn’t a valid URI.
其他结构的要求更为微妙。例如,对于我们提供的各种可用形状的示例,维度字段的每个接口都有一组不同的字段名称(尽管它们都具有相同的类型)。如果我们提供额外的字段以满足要求但数据不太有意义,会发生什么情况?换句话说,如果我们Shape通过调用创建资源CreateShape({ type: 'square', dimension: { radius: 10, length: 10 } })怎么办?显然正方形没有半径,所以这个额外的信息是完全没有用的。
Other structures are more subtle in their requirements. For example, with our example of the various shapes available, each interface for the dimension field has a different set of field names (despite all having the same type). What happens if we provide additional fields such that the requirements are met but the data doesn’t quite make sense? In other words, what if we create a Shape resource by calling CreateShape({ type: 'square', dimension: { radius: 10, length: 10 } })? Obviously a square doesn’t have radius, so this extra information is completely useless.
虽然我们可能会像使用格式不正确的 URI 那样抛出错误,但在这种情况下,更好的选择是验证我们确实需要的输入是否按预期提供,并简单地丢弃任何其他内容,就像我们对未知所做的那样创建或更新任何其他资源时的字段。
While it might be tempting to throw an error as we might with an incorrectly formatted URI, in this case, the better choice is to validate that the inputs we do need are provided as expected and simply discard anything else, as we would do with unknown fields when creating or updating any other resource.
现在我们已经了解了如何处理多态资源上的数据,让我们探索这些资源的行为差异特别的资源。
Now that we have an idea of how to deal with data on a polymorphic resource, let’s explore the behavioral differences of these special resources.
所以到目前为止,我们的讨论完全是关于多态资源的结构和设计,但我们很少谈及如何与这些资源交互。正如我们在 16.1 节中看到的,多态性的部分好处,无论是在编程语言还是 Web API 中,都是我们可以在通用接口而不是特定实现上进行操作,但是操作的结果会有所不同,具体取决于那具体的实现。以我们的countSides()函数为例从 16.1 节开始,我们可以很容易地将其转换为 Web API。
So far, our discussion has been entirely about the structure and design of polymorphic resources, but we’ve said very little about how we might interact with these resources. As we saw in section 16.1, part of the benefit of polymorphism, either in a programming language or a web API, is that we can operate on a generic interface rather than a specific implementation, but the results of the operation will be different depending on that specific implementation. Taking our example of a countSides() function from section 16.1, we could translate this to a web API quite easily.
Listing 16.8 API methods to count sides of different shapes
abstract class ShapeApi { @post("/{id=shapes/*}:countSides") CountShapeSides(req: CountShapeSidesRequest): CountShapeSidesResponse; } interface CountShapeSidesRequest { id: string; } interface CountShapeSidesResponse { sides: number; }
在此示例中,您可以想象响应:正方形将返回{ sides: 4 },而三角形将返回{ sides: 3 },其他形状类型依此类推。简而言之,由于我们选择资源作为通用接口(例如,Shape或Message),我们定义的方法将始终在资源上运行,并且行为中的任何偏差都可以归咎于我们所期望的典型方法不同领域具有不同价值的资源。简而言之,我们早期的设计选择导致了一个非常方便的结果,与资源上的任何其他标准或自定义方法没有太大区别。
In this example, you can imagine the responses: a square would return { sides: 4 }, whereas a triangle would return { sides: 3 }, and so on for the other shape types. In short, thanks to our choice of resource as the generic interface (e.g., Shape or Message), the methods we define will operate as always on the resource, and any deviations in behavior can be blamed on the typical ones we’ve come to expect from resources with differing values for a variety of fields. In short, our earlier design choices have led to a very convenient result not much different from any other standard or custom method on a resource.
但这确实提出了一个真正应该解决的重要问题:如果我们拥有独立的资源并希望单个 API 方法能够对多种资源类型进行操作,那该怎么办?在下一节中,我们将详细探讨这一点并解释为什么它通常是一个坏主意。
But this does raise an important question that really should be addressed: what about the case where we have separate resources and want a single API method to be able to operate on a variety of resource types? In the next section, we’ll explore this in detail and explain why it’s generally a bad idea.
所以到目前为止,我们几乎只讨论了可以采用多种形式的单一资源。这导致 API 方法非常简单,因为它们的行为与对任何其他资源的行为完全相同;然而,还有一种我们甚至没有提到的完全不同类型的多态:多态方法。
So far, we’ve talked almost exclusively about a single resource that can take on many forms. This has led to a great deal of simplicity with API methods in that they behave exactly like they would for any other resource; however, there’s an entirely different type of polymorphism that we haven’t even mentioned: polymorphic methods.
出于本节的目的,我们将多态方法定义为能够对多种不同资源类型进行操作的 API 方法。为了看一个人为的例子,让我们想象一个通用DeleteResource()方法在 API 中,无论资源类型如何,它都能够删除 API 中的任何资源。请特别注意 HTTP URL 映射到"*/*"而不是像"chatRooms/*".
For the purposes of this section, we’ll define a polymorphic method as an API method that is able to operate on multiple different resource types. To see a contrived example, let’s picture a generic DeleteResource() method in an API, which is capable of deleting any resource in the API regardless of the resource type. Take special note of the HTTP URL mapping to "*/*" rather than a specific collection like "chatRooms/*".
Listing 16.9 Polymorphic method supports behavior on different resource types
abstract class GenericApi { @delete("/{id=*/*}") ❶ DeleteResource(req: DeleteResourceReqeuest): void; } interface DeleteResourceRequest { id: string; ❷ }
❶ This URL mapping would correspond to resources of any type.
❷ This field can represent an identifier for any resource type.
看到这个例子时,常见的下意识反应是,“等等。这要简单得多。为什么我们不这样做,而不是为每个资源使用所有这些单独的标准删除方法?” 有趣的是,在技术层面上,这实际上是可行的,因为这里的方法基本上能够处理任何资源的删除。换句话说,由于DeleteResourceRequest接口中提供的标识符是任意字符串,我们可以传入任何有效的资源标识符,引用任何可能的资源类型,资源将被删除。在某种程度上,这有点像在 TypeScript 中定义一个带有方法签名的函数,看起来像function deleteResource(resource: any): void. 然而,像这样的方法往往弊大于利。
It’s a common knee-jerk reaction upon seeing this example to think, “Wait. This is so much simpler. Why don’t we just do this instead of having all of these separate standard delete methods for each resource?” The interesting bit is that on a technical level this would actually work since the method here is capable of handling deletion for basically any resource. In other words, since the identifier provided in the DeleteResourceRequest interface is an arbitrary string, we can pass in any valid resource identifier, referring to any of the possible resource types, and the resource will be deleted. In a way, this is sort of like defining a function with a method signature in TypeScript, looking something like function deleteResource(resource: any): void. However, methods like this tend to cause more harm than good.
重要的是要记住,仅仅因为两个 API 方法具有相同的名称、目标、请求格式或方法签名并不意味着它们是相同的。当我们假装这两种方法是相同的并将它们结合起来时,就像我们在这里所做的那样,我们将来很可能会遇到麻烦。
It’s important to remember that just because two API methods have the same name, goal, request format, or method signature doesn’t mean they’re identical. And when we pretend that the two methods are identical and combine them, as we have here, we’re likely to run into trouble in the future.
在这种多态标准删除方法的情况下,我们可以清楚地删除任何资源,但不能保证所有资源都应该以完全相同的方式删除。例如,我们可能希望支持对特定资源而非所有资源的软删除(第 25 章)或验证请求(第 27 章)。这超出了这个单一示例,并扩展到可能对多种资源类型进行操作的任何方法。
In this case of a polymorphic standard delete method, we can clearly delete any resource, but it’s not guaranteed that all resources should be deleted in exactly the same way. For example, maybe we want to support soft deletion (chapter 25) or validation requests (chapter 27) on specific resources but not all resources. And this goes beyond this single example and extends to any method that might operate on multiple resource types.
任何能够对许多不同类型的资源(或所有资源)进行操作的方法的含义是,这些资源类型的行为彼此非常相似,以至于它们需要一个共享的 API 方法来与它们交互。然而,不知何故,与此同时,它们的表示方式截然不同,以至于它们理应成为完全独立的资源类型。
The implication with any method capable of operating on many different types of resources (or all resources) is that these resource types behave so similarly to one another that they deserve a single shared API method to interact with them. However, somehow, at the same time, their representations are so critically different that they deserve to be entirely separate resource types.
另一个重要的含义是,所讨论的资源将始终并永远保持其行为足够相似,并且多态方法所需行为的任何更改都将应用于所有资源。换句话说,该方法的行为将始终针对所有资源类型进行更改,而不是根据具体情况进行更改。
The other important implication is that the resources in question will, always and forever, remain sufficiently similar in their behavior, and any changes in the desired behavior of the polymorphic method will apply to all the resources. In other words, the behavior of the method will always change for all resource types rather than on a case-by-case basis.
在这两种情况下,我们都取得了相当大的飞跃。首先,我们说资源在行为上是相同的并且需要一个单一的 API 方法,但在表示上也完全不同,需要独立的资源定义。第二,我们假设这种相同的行为是永久固定的,这样所有资源将在整个 API 中统一地改变它们的行为。
In both of these cases we’re making quite large leaps. In the first, we’re saying that resources are identical in behavior and merit a single API method, but also are completely different in representation, meriting independent resource definitions. In the second, we’re assuming that this identical behavior is a permanent fixture such that all resources will change their behavior uniformly across the entire API.
虽然肯定有一种情况可能使这些影响变得合理,但更有可能的是现在或将来其中之一会被揭开。由于我们没有水晶球来帮助预测 API 的未来,因此假设事情会发生变化并且会以他们自己独特的方式发生变化,而不是作为一个统一的群体,会更加安全。基于此,支持多态 API 方法几乎不是一个好主意。
While it’s certainly possible that there is a scenario that makes these implications reasonable, it’s far more likely that either now or in the future one of them will unravel. And since we don’t have a crystal ball to help predict the future of an API, it’s far safer to assume that things will change and will do so in their own unique ways, not as a uniform group. Based on this, it’s almost never a good idea to support polymorphic API methods.
不幸的是,这确实意味着更多的打字。另一方面,API 保持灵活和适应性强,而不是脆弱和笨重的。
Unfortunately, this does mean more typing. On the other hand, the API remains flexible and adaptable rather than brittle and clunky.
在下面的示例我们可以看到我们如何依赖资源的面向资源的多态性Message,从而导致一组简化的 API 方法以及多态性的好处资源布局。
In the following example we can see how we might rely on resource-oriented polymorphism for the Message resource, leading to a simplified set of API methods with the benefits of polymorphism on the resource layout.
Listing 16.10 Final API definition
abstract class ChatRoomApi { @post("/{parent=chatRooms/*}/messages") CreateMessage(req: CreateMessageRequest): Message; } interface CreateMessageRequest { parent: string; resource: Message; } interface Message { id: string; sender: string; type: 'text' | 'image' | 'audio' | 'video'; content: string | Media } interface Media { uri: string; contentType: string; }
多态性in APIs 是一个非常强大的工具,但它也可能很复杂。虽然很多时候它可能是完美的匹配(例如,多种类型Message资源的示例),但在其他情况下,资源可能看起来相似但仍存在一些根本差异。一个好的经验法则是考虑资源是否都是通用资源类型的真正特殊类型(就像PhotoMessage肯定是特定类型的Message)。原因是依赖这种模式确实将资源锁定在一个特定的表示中,这种表示在未来很难(如果不是不可能的话)以不间断的方式解开。
Polymorphism in APIs is a very powerful tool, but it can also be complicated. While many times it might be a perfect fit (e.g., the example of multiple types of Message resources), there may be others where resources might seem similar but still have some fundamental differences. A good rule of thumb is to consider whether the resources are all truly special types of the generic resource type (just like PhotoMessage is certainly a specific type of Message). The reason is that relying on this pattern does lock the resources into a specific representation that is hard (if not impossible) to disentangle in the future in a nonbreaking manner.
最重要的是,出于前面讨论的原因,Web API 中的多态性理想情况下应该关注多态资源而不是多态方法。虽然在某些情况下多态方法可能有用,但请仔细考虑资源行为随时间开始偏离的可能性。这是因为这些类型的方法现在可能会带来便利和更少的打字,但这样做的代价是一副手铐,限制了未来的灵活性应用程序接口。
Above all else, polymorphism in web APIs should ideally be focused on polymorphic resources and not polymorphic methods, for the reasons discussed earlier. While there are possibly some cases where a polymorphic method might be useful, think very carefully about how likely it is that the resources’ behaviors will begin to deviate over time. This is because these types of methods might come with convenience and less typing now, but the price for this is a pair of handcuffs that limit the future flexibility of the API.
想象您正在创建一个 API,该 API 依赖于可以排列到集合或文件夹中的 Web 浏览器的 URL 书签。将这两个概念(文件夹和书签)放入单个多态资源是否有意义?为什么或者为什么不?
Imagine you’re creating an API that relies on URL bookmarks for a web browser that can be arranged into collections or folders. Does it make sense to put these two concepts (a folder and a bookmark) into a single polymorphic resource? Why or why not?
Why should we rely on a string field for storing polymorphic resource types instead of something like an enumeration?
Why should additional data be ignored (e.g., providing a radius for a Shape resource of type square) rather than rejected with an error?
Why should we avoid polymorphic methods? Why is it a bad idea to have a polymorphic set of standard methods (e.g., UpdateResource())?
Polymorphism in APIs allows resources to take on varying types to avoid duplicating shared functionality.
通常,当用户可以合理地期望将多种类型的资源一起列出时(例如,在聊天室中列出消息,其中每条消息可能属于不同类型),依赖多态性是有意义的。
Generally, it makes sense to rely on polymorphism when users can reasonably expect to list resources of multiple types together (e.g., listing messages in a chat room where each message might be of a different type).
The type information for a polymorphic resource should be a string field, but the possible choices for this field should be changed (added to or removed from) carefully to avoid breaking existing client code.
Rather than throwing errors for invalid input data, validation should instead check whether the required data is present and ignore any irrelevant data.
Polymorphic methods should generally be avoided as they prohibit future deviation in behavior over time.
到目前为止,我们看到的大多数 API 设计模式都一次关注一两个资源。在本节中,我们将超越这种狭隘的关注范围,并着眼于旨在处理更大资源集合的设计模式。
Thus far, most of the API design patterns we’ve looked at have focused on one or two resources at a time. In this section, we expand beyond this narrow focus and look at design patterns aimed at dealing with larger collections of resources.
在第 17 章和第 23 章中,我们将探讨如何跨集合复制、移动、导入和导出资源,以及此功能带来的所有令人头疼的问题。在第 18 章中,我们将学习如何应用一组标准方法通过批量操作而不是一次处理一个资源来处理资源块。虽然我们可以将这个想法应用到标准的 delete 方法中,但我们将在第 19 章更进一步,研究如何通过在集合中应用过滤器来一次删除许多资源(我们将在第 22 章)而不是通过了解每个资源的唯一标识符。
In chapters 17 and 23, we’ll explore how to copy, move, import, and export resources across collections, and all of the headaches that come with this functionality. In chapter 18, we’ll learn how to apply the set of standard methods to handle chunks of resources by operating on batches rather than one resource at a time. And while we can apply this idea to the standard delete method, we’ll take things a step further in chapter 19 by looking at how to delete many resources at once by applying a filter across a collection (which we’ll learn more about in chapter 22) rather than by knowing each resource’s unique identifier.
在第 20 章中,我们将探讨如何加载数据点以供聚合使用,而不是像我们迄今为止在资源中看到的那样单独寻址。在第 21 章中,我们将了解如何使用分页以可管理的方式处理大量资源。
In chapter 20, we’ll explore how to load data points for aggregate use rather than individual addressability as we’ve seen thus far with resources. In chapter 21, we’ll look at how to handle large numbers of resources in a manageable way by using pagination.
虽然很少有资源被认为是不可变的,但通常我们可以放心地假设资源的某些属性不会因我们而改变。特别是,资源的唯一标识符是这些属性之一。但是如果我们想重命名资源怎么办?我们怎样才能安全地这样做?此外,如果我们想将资源从属于一个父资源移动到另一个资源怎么办?或者复制资源?我们将为这些操作探索一种安全稳定的方法,涵盖 API 中资源的复制(复制)和移动(更改唯一标识符或更改父级)。
While very few resources are considered immutable, there are often certain attributes of a resource that we can safely assume won’t change out from under us. In particular, a resource’s unique identifier is one of these attributes. But what if we want to rename a resource? How can we do so safely? Further, what if we want to move a resource from belonging to one parent resource to another? Or duplicate a resource? We’ll explore a safe and stable method for these operations, covering both copying (duplication) and moving (changing a unique identifier or changing a parent) of resources in an API.
在理想世界中,资源之间的层次关系经过完美设计并且永远不变。更重要的是,在这个神奇的世界里,API 的用户永远不会犯错或在错误的位置创建资源。他们当然永远不会太晚意识到他们犯了一个错误。在这个世界上,永远不需要重命名或重新定位 API 中的资源,因为我们作为 API 设计者和我们的客户作为 API 消费者,永远不会在我们的资源布局和层次结构中犯任何错误。
In an ideal world, our hierarchical relationships between resources are perfectly designed and forever immutable. More importantly, in this magical world, users of an API never make mistakes or create resources in the wrong location. And they certainly never realize far too late that they’ve made a mistake. In this world, there should never be a need to rename or relocate a resource in an API because we, as API designers, and our customers, as API consumers, never make any mistakes in our resource layout and hierarchy.
这是我们在第 6 章中探索并在 6.3.6 节中详细讨论的世界。不幸的是,这个世界不是我们目前存在的世界,因此我们必须考虑这样一种可能性,即 API 的用户需要能够将资源移动到层次结构中的另一个父级或更改 ID的资源。
This is the world we explored in chapter 6 and discussed in detail in section 6.3.6. Unfortunately, this world is not the one we currently exist in, and therefore we have to consider the possibility that there will come a time where a user of an API needs the ability to move a resource to another parent in the hierarchy or change the ID of a resource.
使事情变得更复杂的是,在某些情况下,用户可能需要复制资源,可能需要复制到层次结构中的其他位置。虽然这两种情况乍一看都非常简单,但就像 API 设计中的大多数主题一样,它们将我们带入了一个充满问题需要回答的兔子洞。此模式的目标是确保 API 使用者能够以安全、稳定且(大部分)简单的方式在整个资源层次结构中重命名和复制资源。
To make things more complicated, there may be scenarios where users need to duplicate resources, potentially to other locations in the hierarchy. And while both of these scenarios seem quite straightforward at a quick glance, like most topics in API design they lead us down a rabbit hole full of questions that need to be answered. The goal of this pattern is to ensure that API consumers can rename and copy resources throughout the resource hierarchy in a safe, stable, and (mostly) simple manner.
自从我们不能使用标准的更新方法来移动或复制资源,我们显然只能选择下一个最佳选择:自定义方法。幸运的是,这些复制和移动自定义方法如何工作的高级概念很简单。与大多数 API 设计问题一样,细节决定成败,例如复制ChatRoom资源以及移动Message资源在不同的ChatRoom父资源之间。
Since we cannot use the standard update method in order to move or copy a resource, we’re left with the obvious next best choice: custom methods. Luckily, the high-level idea of how these copy-and-move custom methods might work is straightforward. As with most API design issues, the devil is in the details, such as in copying a ChatRoom resource as well as moving Message resources between different ChatRoom parent resources.
Listing 17.1 Move-and-copy examples using custom methods
abstract class ChatRoomApi { @post("/{id=chatRooms/*/messages/*}:move") ❶ MoveMessage(req: MoveMessageRequest): Message; ❷ @post("/{id=chatRooms/*}:copy") ❶ CopyChatRoom(req: CopyChatRoomRequest): ChatRoom; ❷ }
❶对于这两种自定义方法,我们都使用 POST HTTP 动词并针对我们要复制或移动的特定资源。
❶ For both custom methods, we use the POST HTTP verb and target the specific resource we want to copy or move.
❷ For both custom methods, the response is always the newly moved or copied resource.
这里没有显示相当多的重要和微妙的问题。首先,当您复制或移动资源时,您是选择一个唯一标识符还是服务这样做就像正在创建新资源一样?在父母之间工作(您可能需要与以前相同的标识符,只是属于不同的父母)与在同一父母内工作(您可能只是想要更改标识符的能力)有区别吗?
Not shown here are quite a few important and subtle questions. First, when you copy or move a resource, do you choose a unique identifier or does the service do so as though the new resource is being created? Is there a difference when working across parents (where you might want the same identifier as before, just belonging to a different parent) versus within the same parent (where you, presumably, just want the ability to change the identifier)?
接下来,当您复制ChatRoom资源时,是否也复制了Message属于该资源的所有资源ChatRoom?如果有大附件怎么办?是否复制了额外的数据?问题还不止于此。我们仍然需要弄清楚如何确保被移动(或复制)的资源当前没有被其他用户交互,如何处理旧标识符,以及任何继承的元数据,如访问控制策略。
Next, when you copy a ChatRoom resource, do all of the Message resources belonging to that ChatRoom get copied as well? What if there are large attachments? Does that extra data get copied? And the questions don’t end there. We still need to figure out how to ensure that resources being moved (or copied) are not currently being interacted with by other users, what to do about the old identifiers, as well as any inherited metadata such as access control policies.
简而言之,虽然此模式仅依赖于特定的自定义方法,但我们远非简单地定义方法并调用它完成。在下一节中,我们将深入研究所有这些问题,以便找到一个 API 界面来确保安全和稳定的资源复制(以及移动)。
In short, while this pattern relies on nothing more than a specific custom method, we’re quite far from simply defining the method and calling it done. In the next section, we’ll dig into all of these questions in order to land on an API surface that ensures safe and stable resource copying (and moving).
作为我们在 17.2 节中看到,我们可以依靠自定义方法在 API 的层次结构中复制和移动资源。我们没有看到的是关于这些自定义方法的一些重要细微差别以及它们实际应该如何工作。让我们从显而易见的开始:我们应该如何确定新移动或复制的资源的标识符?
As we saw in section 17.2, we can rely on custom methods to both copy and move resources around the hierarchy of an API. What we haven’t looked at are some of the important nuances about these custom methods and how they should actually work. Let’s start with the obvious: how should we determine the identifier of the newly moved or copied resource?
作为我们在第 6 章中了解到,通常最好让 API 服务自己为资源选择一个唯一标识符。这意味着当我们创建新资源时,我们可能会指定父资源标识符,但新创建的资源最终会带有一个完全不受我们控制的标识符。这是有充分理由的:当我们选择如何识别我们自己的资源时,我们往往做得很差。在复制和移动资源时会出现相同的情况。从某种意义上说,这两者有点像创建一个看起来与现有资源完全一样的新资源,然后(在移动的情况下)删除原始资源。
As we learned in chapter 6, it’s generally best to allow the API service itself to choose a unique identifier for a resource. This means that when we create new resources we might specify the parent resource identifier, but the newly created resource would end up with an identifier that is completely out of our control. And this is for a good reason: when we choose how to identify our own resources, we tend to do so pretty poorly. The same scenario shows up when it comes to copying and moving resources. In a sense, both of these are sort of like creating a new resource that looks exactly like an existing one and then (in the case of a move) deleting the original resource.
但即使新创建的资源有一个由 API 服务选择的标识符,那应该是什么?事实证明,最方便的选择取决于我们的意图。如果我们尝试重命名资源,使其具有新标识符但存在于资源层次结构中的相同位置,那么选择新标识符对我们来说可能非常重要。如果 API 允许用户指定的标识符,则尤其如此,因为我们可能会将资源从某个有意义的名称重命名为另一个有意义的名称(例如,databases/database-prod类似于databases/database-prod-old). 另一方面,如果我们试图将某些东西从层次结构中的一个位置移动到另一个位置,那么如果新资源具有相同的标识符但属于新的父级(例如,从一个chatRooms/1234/messages/abcdto的现有标识符chatRooms/5678/messages/abcd,请注意 ) 的共性messages/abcd。由于场景差异很大,让我们逐一查看。
But even if the newly created resource has an identifier chosen by the API service, what should that be? It turns out that the most convenient option depends on our intent. If we’re trying to rename a resource such that it has a new identifier but exists in the same position in the resource hierarchy, then it might be quite important for us to choose the new identifier. This is especially so if the API permits user-specified identifiers, since we’re likely to be renaming a resource from some meaningful name to another meaningful name (for example, something like databases/database-prod to databases/database-prod-old). On the other hand, if we’re trying to move something from one position in the hierarchy to another, then it might actually be a better scenario if the new resource has the same identifier but belongs to a new parent (e.g., moving from an existing identifier of chatRooms/1234/messages/abcd to chatRooms/5678/messages/abcd, note the commonality of messages/abcd). Since the scenarios differ quite a bit, let’s look at each one individually.
选择重复资源的标识符非常简单。无论您是将资源复制到相同还是不同的父级,复制方法的行为都应与标准的创建方法相同。这意味着如果您的 API 选择了用户指定的标识符,复制方法还应该允许用户为新创建的资源指定目标标识符。如果它仅支持服务生成的标识符,则不应例外并允许用户指定的标识符仅用于资源复制。(如果是这样,这将是一个漏洞,任何人都可以通过简单地创建资源然后将资源复制到实际预期目的地来选择自己的标识符。)
Choosing an identifier for a duplicate resource turns out to be pretty straightforward. Whether or not you’re copying the resource into the same or a different parent, the copy method should act identically to the standard create method. This means that if your API has opted for user-specified identifiers, the copy method should also permit the user to specify the destination identifier for the newly created resource. If it supports only service-generated identifiers, it should not make an exception and permit user-specified identifiers just for resource duplication. (If it did, this would be a loophole through which anyone could choose their own identifiers by simply creating the resource and then copying the resource to the actual intended destination.)
这样做的结果是,复制资源的请求将接受目标父级(如果资源类型有父级),并且如果允许用户指定的标识符,还将接受目标 ID。
The result of this is that the request to copy a resource will accept both a destination parent (if the resource type has a parent), and, if user-specified identifiers are permitted, a destination ID as well.
Listing 17.2 Copy request interfaces
interface CopyChatRoomRequest { id: string; ❶ destinationId: string; ❷ } interface CopyMessageRequest { id: string; ❶ destinationParent: string; ❸ destinationId: string; ❷ }
❶ We always need to know the ID of the resource to be copied.
❷此字段是可选的。只有当 API 支持用户指定的标识符时,它才应该存在。
❷ This field is optional. It should only be present if the API supports user-specified identifiers.
❸当资源在层次结构中有父级时,我们应该指定新复制的资源应该在哪里结束。
❸ When the resource has a parent in the hierarchy, we should specify where the newly copied resource should end up.
这可能会导致一些令人惊讶的结果。例如,如果 API 不支持用户选择的标识符,则复制任何顶级资源的请求只需要一个参数:要复制的资源的 ID。在另一种情况下,我们可能希望将资源复制到同一个父资源中。在这种情况下,destinationParent字段id将与字段中指向的资源的父级相同.
This can lead to a few surprising results. For example, if the API doesn’t support user-chosen identifiers, the request to copy any top-level resources takes only a single parameter: the ID of the resource to be copied. In another case, it’s possible that we might want to copy a resource into the same parent. In this case, the destinationParent field would be identical to the parent of the resource pointed to in the id field.
此外,即使在 API 支持用户选择的标识符的情况下,我们也可能希望在复制资源时依赖自动生成的 ID。为此,我们只需离开该destinationId领域空白,并允许这是我们向服务表达的方式,“我希望你为我选择目的地标识符。”
Additionally, even in cases where the API supports user-chosen identifiers, we might want to rely on an auto-generated ID when duplicating a resource. To do this, we would simply leave the destinationId field blank and allow that to be the way we express to the service, “I’d like you to choose the destination identifier for me.”
最后,当支持用户指定的标识符时,目标父项中的目标 ID 可能已经被采用。在这种情况下,可能很想恢复为服务器生成的标识符以确保成功复制资源。但是,应该避免这种情况,因为它会破坏用户对新资源目的地的假设。相反,该服务应该返回409 ConflictHTTP 错误的等价物并中止复制手术。
Finally, when user-specified identifiers are supported, it’s possible that the destination ID inside the destination parent might already be taken. In this case, it might be tempting to revert to a server-generated identifier in order to ensure that the resource is successfully duplicated. This should be avoided, however, as it breaks the user’s assumptions about the destination of the new resource. Instead, the service should return the equivalent of a 409 Conflict HTTP error and abort the duplication operation.
什么时候在移动资源方面,我们实际上有两种截然不同的场景。一方面,用户可能最关心将资源重新定位到资源层次结构中的另一个父级,例如将Message资源从一个资源移动ChatRoom到另一个。另一方面,用户可能想要重命名资源,在这种情况下,资源在技术上被移动到具有不同目标 ID 的同一父级。也有可能我们希望同时进行这两项操作(重新定位和重命名)。更复杂的是,我们必须记住,只有当 API 支持用户选择的标识符时,重命名资源才有意义。
When it comes to moving resources, we actually have two subtly different scenarios. On the one hand, users may be most concerned about relocating a resource to another parent in the resource hierarchy, such as moving a Message resource from one ChatRoom to another. On the other hand, users may want to rename a resource, where the resource is technically moved to the same parent with a different destination ID. It’s also possible that we might want to do both at the same time (relocate and rename). To further complicate things, we must remember that renaming resources is only something that makes sense if the API supports user-chosen identifiers.
要处理这些情况,可能会再次使用两个单独的字段,如清单 17.2 所示,其中您有一个目标父级和一个目标标识符。但是在这种情况下,无论如何我们总是知道最终标识符(与我们可能依赖服务器生成的标识符的复制方法相比)。ID 要么与原始资源相同,但父资源不同,要么是我们选择的全新资源。因此,我们可以使用单个destinationId字段并根据是否支持用户选择的标识符对该字段的值实施一些约束。如果是,那么任何完整的标识符都是可以接受的。如果不是,那么唯一有效的新标识符是专门更改 ID 的父部分并保持其余部分不变的标识符。
To handle these scenarios, it might be tempting to use two separate fields again, as in listing 17.2, where you have a destination parent as well as a destination identifier. In this case though, we always know the final identifier no matter what (compared to the copy method where we might rely on a server-generated identifier). The ID is either the same as the original resource except for a different parent or a completely new one of our choosing. As a result, we can use a single destinationId field and enforce some constraints on the value for that field depending on whether user-chosen identifiers are supported. If they are, then any complete identifier is acceptable. If not, then the only valid new identifiers are those that exclusively change the parent portion of the ID and keep the rest the same.
所有这些都将我们引向一个仅接受两个字段的移动请求结构:正在移动的资源的标识符和重定位完成后资源的预期目的地。
All of this leads us to a move request structure that accepts only two fields: the identifier of the resource being moved and the intended destination of the resource after the relocation is complete.
Listing 17.3 Move request interfaces
interface MoveChatRoomRequest { ❶ id: string; ❷ destinationId: string; } interface MoveMessageRequest { id: string; ❷ destinationId: string; ❸ }
❶由于 ChatRoom 资源没有分层父级,因此只有在 API 支持用户指定的标识符时,此方法才有意义。
❶ Since ChatRoom resources have no hierarchical parents, this method only makes sense if user-specified identifiers are supported by the API.
❷ We always need to know the ID of the resource to be copied.
❸由于 Message 资源有父级,我们可以通过更改目标 ID 将资源移动到不同的父级。
❸ Since Message resources have parents, we can move the resource to different parents by changing the destination ID.
如您所见,对于顶级资源(没有父资源的资源),只有当 API 支持用户指定的标识符时,整个移动方法才有意义。这与 copy 方法形成鲜明对比,后者在这种情况下仍然有意义。问题主要在于移动实现了两个目标:将资源重新定位到不同的父资源并重命名资源。后者仅在支持用户指定标识符的情况下才有意义。前者仅在资源具有父级时才有意义。如果这些都不适用,则移动方法变得完全无关紧要,根本不应实施。
As you can see, for top-level resources (those with no parents) the entire move method only makes sense if the API supports user-specified identifiers. This stands in stark contrast to the copy method, which still makes sense in this case. The issue is primarily that moving accomplishes two goals: relocating a resource to a different parent and renaming a resource. The latter only makes sense given support for user-specified identifiers. The former only makes sense if the resource has a parent. In cases where neither of these apply, the move method becomes entirely irrelevant and should not be implemented at all.
现在我们已经了解了复制和移动资源的请求的形状和结构,让我们继续前进,看看我们如何处理更多复杂的场景。
Now that we have an idea of the shape and structure of requests to copy and move resources around, let’s keep moving forward and look at how we handle even more complicated scenarios.
所以到目前为止,我们的工作条件是我们打算移动或复制的每个资源都是完全独立的。本质上,我们的主要假设是资源本身没有需要在单个记录之外复制的其他信息。这个假设当然使我们的工作更容易一些,但不幸的是,它不一定是一个安全的假设。相反,我们必须假设还有与需要移动或复制的资源关联的其他数据。我们如何处理这个?我们应该为这些额外的数据烦恼吗?
So far, we’ve worked with the condition that each resource we intend to move or copy is fully self-contained. In essence, our big assumption was that the resource itself has no additional information that needs to be copied outside of a single record. This assumption certainly made our work a bit easier, but unfortunately it’s not necessarily a safe assumption. Instead, we have to assume that there will be additional data associated with resources that need to be moved or copied as well. How do we handle this? Should we even bother with this extra data?
简短的回答是肯定的。简而言之,外部数据和相关资源(例如,子资源)很少是无目的的。因此,在不包含此关联数据的情况下复制或移动资源可能会导致令人惊讶的结果:新的目标资源与源资源的行为不同。由于良好 API 的关键目标之一是可预测性,因此确保新复制或移动的资源在尽可能多的方面与源资源相同变得格外重要。我们如何做到这一点?
The short answer is yes. Put simply, the external data and associated resources (e.g., child resources) are rarely there without purpose. As a result, copying or moving a resource without including this associated data is likely to lead to a surprising result: a new destination resource that doesn’t behave the same as the source resource. And since one of the key goals of a good API is predictability, it becomes exceptionally important to ensure that the newly copied or moved resource is identical to the source resource in as many ways as possible. How do we make this happen?
首先,无论是复制还是移动,都必须包含所有子资源。例如,这意味着如果我们复制或移动ChatRoom资源,则所有Message子资源还必须使用新的父 ID 进行复制或移动。显然,这比更新一行要多得多。此外,更新次数取决于子资源的数量,这意味着,如您所料,复制具有少量子资源的资源比复制具有大量子资源的类似资源所花费的时间要少得多。表 17.1 显示了移动或复制操作前后资源标识符的示例名词
First, whether copied or moved, all child resources must be included. For example, this means that if we copied or moved a ChatRoom resource, all the Message child resources must also be copied or moved with the new parent ID. Obviously this is far more work than updating a single row. Further, the number of updates is dependent on the number of child resources, meaning that, as you’d expect, copying a resource with few child resources will take far less time than a similar resource with a large number of child resources. Table 17.1 shows an example of the identifiers of resources before and after a move or copy operation.
ChatRoom表 17.1 移动(或复制)资源后的新旧标识符
Table 17.1 New and old identifiers after moving (or copying) a ChatRoom resource
虽然我们的复制问题在这里已经完成,但不幸的是,当资源被移动时情况并非如此。相反,在我们更新完所有 ID 之后,我们实际上产生了一个全新的问题:以前指向这些资源的任何其他地方现在都有一个死链接,尽管资源仍然存在!在下一节中,我们将探讨如何处理这些引用,包括内部引用和外部的。
While our issues with copying are complete here, this is unfortunately not the case when resources have been moved. Instead, after we’ve finished updating all of the IDs, we’ve actually created an entirely new problem: anywhere else that previously pointed at these resources now has a dead link, despite the resource still existing! In the next section, we’ll explore how to handle these references, both internal and external.
尽管我们已经决定所有子资源都必须与目标资源一起复制或移动,到目前为止我们还没有谈及相关资源。例如,假设我们的聊天 API 支持报告或标记可能不适合MessageReviewReport资源的消息. 此资源有点像消息的支持案例,该消息已被标记为不当且应由支持团队进行审查。此资源不一定是Message资源(甚至ChatRoom资源)的子资源,但它确实引用了Message它所针对的资源。
While we’ve decided that all child resources must be copied or moved along with the target resource, we’ve said nothing so far about related resources. For example, let’s imagine that our chat API supports reporting or flagging messages that might be inappropriate with a MessageReviewReport resource. This resource is sort of like a support case of a message that has been flagged as inappropriate and should be reviewed by the support team. This resource is not necessarily a child of a Message resource (or even a ChatRoom resource), but it does reference the Message resource that it is targeting.
清单 17.4 引用 Message 资源的 MessageReviewReport 资源
Listing 17.4 A MessageReviewReport resource that references a Message resource
interface MessageReviewReport { id: string; messageId: string; ❶ reason: string; }
❶ This field contains the ID of a Message resource, acting as a reference.
messageId该资源的存在(以及它是一个字段的事实是对Message资源的引用)导致了几个明显的问题。首先,当您复制 a 时Message,它是否也应该复制所有MessageReviewReport引用原始消息的资源?其次,如果Message资源被移动,所有MessageReviewReport引用新移动资源的资源是否也应该更新?当我们记住复制和移动Message资源可能不是专门针对Message资源的最终用户请求,而是可能是针对父ChatRoom资源的请求的级联结果时,这些问题变得更加复杂!
The existence of this resource (and the fact that it’s a messageId field is a reference to a Message resource) leads to a couple of obvious questions. First, when you duplicate a Message, should it also duplicate all MessageReviewReport resources that reference the original message? And second, if a Message resource is moved, should all MessageReviewReport resources that reference the newly moved resource be updated as well? These questions get even more complicated when we remember that copying and moving Message resources may not be due specifically to an end-user request targeted at the Message resource but instead could be the cascading result of a request targeted at the parent ChatRoom resource!
对于在所有这些情况下最好做什么没有单一的正确答案应该不足为奇,但让我们从简单的开始,例如相关资源应该如何响应正在移动的资源。
It should come as no surprise that there is no single right answer to what’s best to do in all of these scenarios, but let’s start with the easy ones, such as how related resources should respond to resources being moved.
在在许多关系数据库系统中,有一种方法可以配置系统,以便当某些数据库行发生更改时,这些更改可以级联并更新数据库中其他位置的行。这是一个非常有价值但资源密集型的特性,它通过尝试取消引用新的无效指针来确保您永远不会遇到与分段错误等效的数据库。当谈到在 API 内移动资源时,我们理想情况下希望争取相同的结果。
In many relational database systems, there is a way to configure the system such that when certain database rows change, those changes can cascade and update rows elsewhere in the database. This is a very valuable, though resource intensive, feature that ensures you never have the database equivalent of a segmentation fault by trying to de-reference a newly invalid pointer. When it comes to moving resources inside an API, we ideally want to strive for the same result.
不幸的是,这只是需要移动自定义方法的那些非常困难的事情之一。我们不仅需要跟踪所有引用被移动目标的资源,还需要跟踪与父资源一起移动的子资源,并确保引用这些子资源的任何其他资源也得到更新。可以想象,这是非常复杂的,也是不鼓励重命名或重新定位资源的众多原因之一!
Unfortunately, this is simply one of those very difficult things that comes along with needing a move custom method. Not only do we need to keep track of all the resources that refer to the target being moved, we also need to keep track of the child resources being moved along with the parent and ensure that any other resources referencing those children are updated as well. As you can imagine, this is extraordinarily complex and one of the many reasons renaming or relocating resources is discouraged!
例如,遵循这个指南意味着如果我们要移动Message一个引用它的资源MessageReviewReport,我们也需要更新它MessageReviewReport。此外,如果我们将ChatRoom作为父资源的资源移动到Message带有 a 的this 上MessageReviewReport,我们将不得不做同样的事情,因为Message将由于是 的子资源而被移动ChatRoom。
For example, following this guideline means that if we were to move a Message resource that had a MessageReviewReport referring to it, we would also need to update that MessageReviewReport as well. Further, if we moved the ChatRoom resource that was the parent to this Message with a MessageReviewReport, we would have to do the same thing because the Message would be moved by virtue of being a child resource of the ChatRoom.
下面我们就简单的看一下在复制自定义的时候我们应该如何处理相关的资源方法。
Now let’s take a brief look at how we should handle related resources when it comes to the copy custom method.
尽管移动资源肯定会让相关资源很头疼,复制资源也是一样吗?事实证明,它也会引起头痛,但完全是另一种类型的头痛。我们有一个艰难的设计决策,而不是要处理一个艰难的技术问题。原因很简单:是否将相关资源与目标资源一起复制在很大程度上取决于具体情况。
While moving resources certainly creates quite a headache for related resources, is the same thing true of copying resources? It turns out that it also creates a headache, but it’s a headache of a different type entirely. Instead of having a hard technical problem to handle, we have a hard design decision. The reason for this is pretty simple: whether or not to copy a related resource along with a target resource depends quite a bit on the circumstances.
例如,拥有一个MessageReviewReport资源当然很重要,但是如果我们复制一个Message资源,我们就不想完全按原样复制报告。MessageReviewReport相反,允许 a引用多个资源可能更有意义Message,而不是复制报告,我们可以简单地将新复制的Message资源添加到被引用的列表中。
For example, a MessageReviewReport resource is certainly important to have around, but if we duplicate a Message resource we wouldn’t want to duplicate the report exactly as it is. Instead, perhaps it makes more sense to allow a MessageReviewReport to reference more than one Message resource, and rather than duplicating the report we can simply add the newly copied Message resource to the list of those being referenced.
其他资源不应该被复制。例如,当我们复制ChatRoom资源时,我们不应该复制User资源被列为该资源中的成员。最终,要点是某些资源与目标一起复制是有意义的,而其他资源则根本不会。这是一个远没有那么复杂的技术问题,而是在很大程度上取决于 API 的预期行为。
Other resources should simply never be duplicated. For example, when we copy a ChatRoom resource we should never copy the User resources that are listed as members in that resource. Ultimately, the point is that some resources will make sense to copy alongside the target while others simply won’t. It’s a far less complicated technical problem and instead will depend quite a lot on the intended behavior for the API.
现在我们已经介绍了在面对这些新的自定义方法时保持引用完整性的细节,让我们探讨一下当我们将其扩展到包含我们外部的引用时的情况控制。
Now that we’ve covered the details on maintaining referential integrity in the face of these new custom methods, let’s explore how things look when we expand that to encompass references outside our control.
尽管大多数对 API 中资源的引用将存在于同一 API 中的其他资源中,但情况并非总是如此。在许多情况下,资源将在整个 Internet 上被引用,特别是与存储文件或其他非结构化数据相关的任何内容。这提出了一个非常明显的问题,因为这整章都是关于移动资源和破坏所有外部引用的。例如,假设我们有一个跟踪文件的存储系统。这些File资源显然能够在整个互联网上共享,这意味着我们将在各处获得对这些资源的大量引用!我们可以做什么?
While most references to resources in an API will live inside other resources in that same API, this isn’t always the case. There are many scenarios where resources will be referenced from all over the internet, in particular anything related to storing files or other unstructured data. This presents a pretty obvious problem given that this entire chapter is about moving resources and breaking those external references from all over. For example, let’s imagine that we have a storage system that keeps track of files. These File resources are clearly able to be shared all over the internet, which means we’ll have lots of references to these resources all over the place! What can we do?
Listing 17.5 File resources are likely to be referenced outside of the API
abstract class FileApi { @post("/files") CreateFile(req: CreateFileRequest): File; @get("/{id=files/*}") ❶ GetFile(req: GetFileRequest): File; } interface File { id: string; content: Uint8Array; }
❶这些 URI 可能用于从 API 外部引用给定的文件资源。
❶ These URIs might be used to reference a given File resource from outside the API.
重要的是要记住,互联网并不是参照完整性的完美体现。我们大多数人都会遇到 HTTP404 Not Found错误一直如此,因此将引用完整性要求引入 API 内部并将其扩展到整个互联网是不太公平的。当我们向世界提供网络资源时,我们很少真正打算签署一份终身合同,以在永恒的剩余时间里继续提供具有完全相同的字节和完全相同的名称的相同资源。相反,我们经常提供资源,直到它不再有意义为止。关键是提供给开放互联网的资源通常是尽力而为,很少有终身保证。
It’s important to remember that the internet is not exactly a perfect representation of referential integrity. Most of us encounter HTTP 404 Not Found errors all the time, so it’s not quite fair to take the referential integrity requirements internal to an API and expand them to the entire internet. Very rarely when we provide a web resource to the world do we actually intend to sign a lifelong contract to continue providing that same resource with those exact same bytes and exact same name for the rest of eternity. Instead, we often provide the resource until it no longer makes sense. The point is that resources provided to the open internet are often best effort and rarely come with a lifetime guarantee.
在这种情况下,我们的示例File资源可能最好根据同一组准则进行分类:它会一直存在,直到它碰巧被移动。由于美国司法部的紧急请求,这可能是明天,也可能是明年,因为存储文件的成本不再有意义。关键是我们应该预先承认外部参照完整性不是 API 的关键目标,并关注更重要的问题在手。
In this case, our example File resource is probably best categorized under this same set of guidelines: it’ll be present until it happens to be moved. This could be tomorrow due to an urgent request from the US Department of Justice, or it could be next year because the cost of storing the file didn’t make sense anymore. The point is that we should acknowledge up front that external referential integrity is not a critical goal for an API and focus on the more important issues at hand.
所以到目前为止,我们复制或移动的所有资源都是您存储在关系数据库或控制平面中的资源数据。如果我们想要复制或移动恰好指向原始字节块的资源,例如File清单 17.5 中的资源,情况会怎样?我们还应该将这些字节复制到我们的底层存储中吗?
So far, all the resources we’ve copied or moved have been the kind you’d store in a relational database or control plane data. What about scenarios where we want to copy or move a resource that happens to point to a raw chunk of bytes, such as in our File resource in listing 17.5? Should we also copy those bytes in our underlying storage?
这实际上是计算机科学中一个经过充分研究的问题,并且在许多编程语言中都表现为按值复制与按引用复制的问题的变量。当按值复制变量时,所有基础数据都会被复制,新复制的变量与旧变量完全分开。当通过引用复制变量时,基础数据保留在同一位置,并创建一个恰好指向原始数据的新变量。在这种情况下,改变一个变量的数据也会导致另一个变量的数据被更新。
This is actually a well-studied problem in computer science and shows up in many programming languages as the question of copy by value versus copy by reference of variables. When a variable is copied by value, all of the underlying data is duplicated and the newly copied variable is entirely separate from the old variable. When a variable is copied by reference, the underlying data remains in the same place and a new variable is created that happens to point to the original data. In this case, changing the data of one variable will also cause the data of the other variable to be updated.
Listing 17.6 Pseudo-code for copying by reference vs. by value
let original = [1, 2, 3]; let copy_by_reference = copyByReference(original); ❶ let copy_by_value = copyByValue(original); copy_by_reference[1] = 'ref'; ❷ copy_by_value[1] = 'val';
❶ First we copy the original list twice, once by value and once by reference.
❷ Then we update each of the copies (results shown in figure 17.1).
清单 17.6 显示了一些伪代码的示例,它执行两个副本(一个按值,另一个按引用),然后更新结果数据。最终值如图 17.1 所示。如您所见,尽管有三个变量,但只有两个列表,并且对copy_by_reference变量的更改在原始列表中也可见。
Listing 17.6 shows an example of some pseudo-code that performs two copies (one by value, another by reference) and then updates the resulting data. The final values are then shown in figure 17.1. As you can see, despite having three variables, there are only two lists, and the changes to the copy_by_reference variable are also visible in the original list.
Figure 17.1 Visualization of copying by reference vs. by value
在移动指向此类外部数据的资源时,答案很简单:不理会底层数据。这意味着虽然我们可能会重新定位资源记录本身,但数据应该保持不变。例如,重命名File资源应该更改该资源的主目录,但不应将底层字节移动到其他任何地方。
When moving resources that point to this type of external data, the answer is simple: leave the underlying data alone. This means that while we might relocate the resource record itself, the data should remain untouched. For example, renaming a File resource should change the home of that resource, but the underlying bytes should not be moved anywhere else.
复制资源时,答案很明确,但恰好有点复杂。通常,此类外部数据的最佳解决方案是从仅按引用复制开始。在那之后,如果数据发生变化,我们应该复制所有底层数据并应用更改,因为当我们可以通过引用复制时复制一大堆字节可能会很浪费。但是,我们当然不能让对重复资源的更改显示为对原始资源的更改,因此一旦要进行更改,我们就必须制作完整副本。
When copying resources, the answer is clear but happens to be a bit more complicated. In general, the best solution for this type of external data is to begin by copying by reference only. After that, if the data ever happens to change, we should copy all of the underlying data and apply the changes because it can be wasteful to copy a whole bunch of bytes when we might be able to get away with copying by reference. However, we certainly cannot have changes on the duplicate resource showing up as changes on the original resource, so we must make the full copy once changes are about to be made.
这种策略称为写时复制,对于存储系统来说很常见,其中许多会在引擎盖下为您完成繁重的工作。换句话说,您可以通过调用存储系统的copy()函数来逃避并且它将正确处理所有语义以仅在数据稍后时按值进行真正的复制改变了。
This strategy, called copy on write, is quite common for storage systems, and many of them will do the heavy lifting for you under the hood. In other words, you might be able to get away with calling a storage system’s copy() function and it will properly handle all the semantics to do a true copy by value only when the data is later altered.
在在许多 API 中,有一些策略或元数据集由子资源继承到父资源。例如,让我们想象一个我们想要控制不同聊天室中的消息长度的世界。此长度限制可能因房间而异,因此它是在ChatRoom资源上设置的最终应用于Message子资源的属性。
In many APIs, there is some set of policy or metadata that is inherited by a child resource to a parent resource. For example, let’s imagine a world where we want to control the length of messages in different chat rooms. This length limit might vary from one room to another, so it would be an attribute set on the ChatRoom resource that ultimately applies to the Message child resources.
Listing 17.7 ChatRoom resources with conditions inherited by children
interface ChatRoom { id: string; // ... messageLengthLimit: number; ❶ }
❶该设置被子Message资源继承,用于限制消息内容的长度。
❶ This setting is inherited by child Message resources to limit the length of the message content.
这一切都很好,但是当Message资源从一个ChatRoom资源复制到另一个资源时会变得更加混乱。最常见的潜在问题是当这些不同的继承限制碰巧发生冲突时,例如Message恰好满足其当前ChatRoom资源的长度要求的资源被复制到另一个ChatRoom具有更严格要求的资源,而不是当前存在的资源所满足的。换句话说,如果我们想将一个Message有 140 个字符的资源复制到一个ChatRoom只允许 100 个字符的消息中,会发生什么情况?
This is all well and good but gets more confusing when Message resources are being copied from one ChatRoom resource to another. The most common potential problem is when these different inherited restrictions happen to conflict, such as a Message that happens to meet the length requirements of its current ChatRoom resource being copied into another ChatRoom with more stringent requirements, not met by the resource as it exists currently. In other words, what happens if we want to copy a Message resource that has 140 characters into a ChatRoom that only allows messages of 100 characters?
一种选择是简单地允许资源打破规则,可以这么说,ChatRoom尽管超出了长度限制,但仍存在于资源内部。虽然这在技术上可行,但它引入了进一步的复杂性,因为此资源上的标准更新方法可能会开始失败,直到资源被修改以符合父资源的规则。通常,这会使目标资源有效地不可变,从而使那些有兴趣在不修改内容长度的情况下更改资源的其他方面的人感到困惑和沮丧。
One option is to simply permit the resource to break the rules, so to speak, and exist inside the ChatRoom resource despite being beyond the length limits. While this technically works, it introduces further complications as the standard update methods on this resource may begin to fail until the resource has been modified to conform to the rules of the parent. Often, this can make the destination resource effectively immutable, causing confusion and frustration for those interested in changing other aspects of the resource without modifying the content length.
另一种选择是截断或以其他方式修改传入资源,以使其遵守目标父级的规则。虽然这在技术上也是可以接受的,但对于不了解目标资源要求的用户来说可能会感到惊讶。特别是,在永久破坏数据的这些情况下,这种“强制适应”解决方案的不可预测性打破了良好 API 的最佳实践。这种解决方案的不可逆性是应该避免的另一个原因。
Another option is to truncate or otherwise modify the incoming resource so that it adheres to the rules of the destination parent. While this is technically acceptable as well, it can be surprising to users who were not aware of the destination resource’s requirements. In particular, in these cases where you are permanently destroying data, this type of “force it to fit” solution breaks the best practices for good APIs by being unpredictable. The irreversible nature of this type of solution is yet another reason why it should be avoided.
更好的选择是简单地拒绝传入的资源并由于违反这些规则而中止复制或移动操作。这确保用户能够决定如何使操作成功,无论是改变存储在资源中的长度要求,还是在尝试复制或移动数据之前ChatRoom截断或删除任何有问题的资源。Message
A better option is to simply reject the incoming resource and abort the copy or move operation due to the violation of these rules. This ensures that users have the ability to decide what to do to make the operation succeed, whether that is altering the length requirement stored in the ChatRoom resource or truncating or removing any offending Message resources before attempting to copy or move the data.
这也适用于由于资源是实际目标的子资源或相关资源而触发复制的情况。在这些情况下,任何类型的验证检查失败或由于从新目标父级继承的任何元数据而出现的问题都应该导致整个操作失败,并有一个明确的原因来阻止操作的所有失败。然后用户可以检查结果并决定是放弃手头的任务还是在解决问题后重试操作报道。
This also applies to cases where copying is triggered by virtue of the resource being a child or related resource to the actual target. In these scenarios, a failure of any sort of validation check or problem due to any inherited metadata from the new destination parent should cause the entire operation to fail, with a clear reason for all of the failures standing in the way of the operation. The user then has the ability to inspect the results and decide whether to abandon the task at hand or retry the operation after fixing the issues reported.
作为我们在本章中已经看到,复制和移动方法的主要收获应该是它们可能比看起来更复杂和资源密集。虽然在某些情况下它们可能非常无害(例如,复制没有相关资源或子资源的资源),但其他情况可能涉及复制和更新 API 中数百或数千个其他资源。这提出了一个相当大的问题,因为当没有其他人使用 API 和修改底层资源时,很少会发生这些复制或移动操作。面对不稳定的数据集,我们如何确保这些操作成功完成?此外,重要的是,如果在此过程中遇到错误,我们能够撤消已经完成的工作。简而言之,我们要确保我们的移动和复制操作都发生在事务的上下文中。
As we’ve seen throughout this chapter, the key takeaway of both the copy and move methods should be that they can be far more complex and resource intensive than they look. While in some cases they can be pretty innocuous (e.g., copying a resource with no related or child resources) others can involve copying and updating hundreds or thousands of other resources in the API. This presents a pretty big problem because it’s rare that these copy or move operations happen when no one else is using the API and modifying the underlying resources. How do we ensure that these operations complete successfully in the face of a volatile data set? Further, it’s important that if an error is encountered along the way we are able to undo the work we’ve done. In short, we want to make sure that both our move and copy operations occur in the context of a transaction.
有趣的是,当涉及到数据存储层时,复制和移动操作往往以完全不同的方式工作。在复制操作的情况下,我们将主要进行查询以读取数据,然后根据这些条目在存储系统中创建新条目。另一方面,在移动操作的情况下,我们将主要更新存储系统中的现有条目,修改标识符以将资源从一个地方移动到另一个地方,并更新可能引用新资源的现有资源修改后的资源。虽然两者的理想解决方案是相同的,但遇到的问题略有不同,因此,我们将这两者分开解决更有意义。
Interestingly, when it comes to the data storage layer, the copy and move operations tend to work in pretty different ways. In the case of the copy operation, we’ll make mostly queries to read data and then create new entries in the storage system based on those entries. In the case of the move operation, on the other hand, we’ll mostly update existing entries in the storage system, modify identifiers in place in order to move resources from one place to another, and update the existing resources that might reference the newly modified resources. While the ideal solution to both is the same, the problems encountered are slightly different, and, as a result, it makes more sense for us to address these two separately.
在在复制资源(及其子资源和相关资源)的情况下,我们在原子性方面的重点是数据一致性。换句话说,我们要确保我们最终在新目的地获得的数据与我们启动复制操作时存在的数据完全相同,通常称为数据快照。如果您的 API 构建在支持时间点快照或类似事务的存储系统之上,那么这个问题就简单多了。您可以在读取源数据时简单地指定快照时间戳或修订标识符,然后再将其复制到新位置或在单个数据库事务中执行整个操作。然后,如果在此期间发生任何变化,那是完全可以接受的。
In the case of copying a resource (and its children and related resources), our focus when it comes to atomicity is about data consistency. In other words, we want to make sure that the data we end up with in our new destination is exactly the same data that existed at the time we initiated the copy operation, often referred to as a snapshot of the data. If your API is built on top of a storage system that supports point-in-time snapshots or transactions like this, then this problem is a lot more straightforward. You can simply specify the snapshot timestamp or revision identifier when reading the source data before copying it to the new location or perform the entire operation inside a single database transaction. Then, if anything happens to change in the meantime, that’s completely acceptable.
如果您没有那么奢侈,还有其他两种选择。首先,您可以简单地承认这是不可能的,并且复制的任何数据都将更像是一段时间内的数据污迹,而不是来自单个时间点的一致快照。这当然很不方便,并且可能会造成混淆,尤其是对于极不稳定的数据集;然而,鉴于技术限制和正常运行时间要求,它可能是唯一可用的选择。
If you don’t have that luxury, there are two other options. First, you can simply acknowledge that this is not possible and that any data copied will be more of a smear of data across a stretch of time rather than a consistent snapshot from a single point in time. This is certainly inconvenient and can be potentially confusing, especially with extremely volatile data sets; however, it may be the only option available given the technology constraints and up-time requirements.
接下来,我们可以在 API 级别(通过禁用所有修改数据的 API 调用)或数据库级别(防止对数据的所有更新)锁定数据以供写入。虽然并不总是可行,但这种方法有点像“大锤”选项,可确保这些操作期间的一致性,因为它将确保数据与复制操作时出现的完全一样,特别是因为数据被锁定并且禁止在操作期间进行所有更改。
Next, we can lock the data for writing at either the API level (by disabling all API calls that modify data) or at the database level (preventing all updates to the data). While not always feasible, this method is sort of the “sledgehammer” option for ensuring consistency during these operations, as it will ensure that the data is exactly as it appeared at the time of the copy operation, specifically because the data was locked down and prohibited from all changes for the duration of the operation.
一般而言,当然不推荐使用此选项,因为它基本上提供了一种攻击 API 服务的简单方法:只需发送大量复制操作即可。因此,如果您的存储系统不支持时间点快照或事务语义,这是不鼓励支持复制资源的另一个原因应用程序接口。
In general, this option is certainly not recommended as it basically provides an easy way to attack your API service: simply send lots and lots of copy operations. As a result, if your storage system doesn’t support point-in-time snapshots or transactional semantics, this is yet another reason to discourage supporting copying resources around the API.
不像复制数据、移动数据更多地依赖于更新资源,因此我们的关注点略有不同。乍一看,似乎我们真的没有任何大问题,但事实证明数据一致性仍然是一个问题。要了解原因,让我们想象一下,我们有一个MessageReviewReport指向Message我们刚刚移动的点。在这种情况下,我们需要更新MessageReviewReportto 指向Message资源的新位置。但是,如果同时有人更新MessageReviewReport指向不同的消息怎么办?一般来说,我们需要确保相关资源自上次评估是否需要更新以指向新移动的资源以来没有发生变化。
Unlike copying data, moving data depends a lot more on updating resources, and therefore we have slightly different concerns. At first glance, it might seem like we really don’t have any major problems, but it turns out that data consistency is still an issue. To see why, let’s imagine that we have a MessageReviewReport that points at the Message we just moved. In this case, we need to update the MessageReviewReport to point to the Message resource’s new location. But what if someone updated the MessageReviewReport to point to a different message in the meantime? In general, we need to be sure that related resources haven’t been changed since we last evaluated whether they needed to be updated to point to the newly moved resource.
为此,我们有与复制操作类似的选项。首先,最好的选择是使用一致的快照或数据库事务来确保正在完成的工作发生在一致的数据视图上。如果那不可能,那么我们可以锁定数据库或 API 以进行写入,以确保数据在移动操作期间保持一致。正如我们之前所指出的,这通常是一种危险的策略,但它可能是一种必要的邪恶。
To do this, we have similar options to our copying operation. First, the best choice is to use a consistent snapshot or database transaction to ensure that the work being done happens on a consistent view of the data. If that’s not possible, then we can lock the database or the API for writes to ensure that the data is consistent for the duration of the move operation. As we noted before, this is generally a dangerous tactic, but it may be a necessary evil.
最后,我们可以简单地忽略这个问题并抱有最好的希望。与复制操作不同,忽略移动问题会导致比随着时间的推移简单地涂抹数据更糟糕的结果。如果我们不尝试在移动过程中获得一致的数据视图,我们实际上会冒撤消先前由其他更新提交的更改的风险。例如,如果 aMessageReviewReport被标记为由于移动而需要更新,并且有人同时修改了该资源的目标,则移动操作很可能会覆盖该更新,就好像它从未在一开始就收到过一样。虽然这可能不会对所有 API 造成灾难性影响,但这肯定是一种不好的做法,如果有的话应该避免全部可能的。
Finally, we can simply ignore the problem and hope for the best. Unlike a copy operation, ignoring the problem with a move results in a far worse outcome than a simple smear of data over time. If we don’t attempt to get a consistent view of the data during a move we actually run the risk of undoing changes that were previously committed by other updates. For example, if a MessageReviewReport is marked as needing to be updated due to a move and someone modifies the target of that resource in the meantime, it’s very possible the move operation will overwrite that update as though it had never been received in the first place. While this might not be catastrophic to all APIs, it’s certainly bad practice and should be avoided if at all possible.
作为我们已经看到,API 定义本身并不像该 API 的行为那么复杂,尤其是在处理继承的元数据、子资源、相关资源以及与参照完整性相关的其他方面时。然而,为了总结我们迄今为止探索的一切,清单 17.8 显示了支持复制ChatRoom资源(支持用户指定的标识符)和移动Message资源的最终 API 定义之间父母。
As we’ve seen, the API definition itself is not nearly as complex as the behavior of that API, particularly when it comes to handling inherited metadata, child resources, related resources, and other aspects related to referential integrity. However, to summarize everything we’ve explored so far, listing 17.8 shows a final API definition that supports copying ChatRoom resources (with supporting user-specified identifiers) and moving Message resources between parents.
Listing 17.8 Final API definition
abstract class ChatRoomApi { @post("/{id=chatRooms/*}:copy") CopyChatRoom(req: CopyChatRoomRequest): ChatRoom; @post("/{id=chatRooms/*/messages/*}:move") MoveMessage(req: MoveMessageRequest): Message; } interface ChatRoom { id: string; title: string; // ... } interface Message { id: string; content: string; // ... } interface CopyChatRoomRequest { id: string; destinationParent: string; } interface MoveMessage { id: string; destinationId: string; }
希望在看到支持移动和复制操作有多么复杂之后,您的第一个想法是应尽可能避免使用这两种操作。虽然这些操作起初看起来很简单,但事实证明,行为要求和限制可能非常难以正确实施。更糟糕的是,后果可能非常可怕,导致数据丢失或损坏。
Hopefully after seeing how complicated it is to support move and copy operations, your first thought is that these two should be avoided whenever possible. While these operations seem simple at first, it turns out that the behavioral requirements and restrictions can be exceptionally difficult to implement correctly. To make things worse, the consequences can be pretty dire, resulting in lost or corrupted data.
也就是说,复制和移动并不同样复杂,并且在 API 中的地位也不相同。在许多情况下,复制资源实际上可能是一项关键功能。另一方面,只有在错误或资源布局不当的情况下,才需要移动资源。因此,即使布局最好的 API 也可能在支持复制方法方面发现很大的价值,但最好在决定实施移动方法之前重新评估资源布局。通常情况下,事实证明需要跨父资源移动资源(或重命名资源)是由资源标识符选择不当或依赖本应是引用关系的父子关系引起的。
That said, copy and move are not equally complex and do not have equal standing in an API. In many cases, copying resources can actually be a critical piece of functionality. Moving resources, on the other hand, often only becomes necessary as the result of a mistake or poor resource layout. As a result, while even the best laid out APIs might find a great deal of value in supporting the copy method, it’s best to reevaluate the resource layout before deciding to implement the move method. Often, it turns out that a need to move resources across parents (or rename resources) is caused by poorly chosen resource identifiers or by relying on a parent–child relationship in what should have been a referential relationship.
最后,重要的是要注意,虽然本章中阐述的移动和复制操作行为的复杂性可能很繁重且难以实现,但这里的偷工减料更有可能导致严重的后果路。
Finally, it’s important to note that while the complexity spelled out in this chapter for the behavior of the move and copy operations might be onerous and challenging to implement, cutting corners here is far more likely to lead to nasty consequences down the road.
When copying a resource, should all child resources be copied as well? What about resources that reference the resource being duplicated?
How can we maintain referential integrity beyond the borders of our API? Should we make that guarantee?
When copying or moving data, how can we be sure that the resulting data is a true copy as intended and not a smear of data as it’s being modified by others using the API?
想象一下,我们正在将资源从一个父级移动到另一个父级,但是父级具有不同的安全和访问控制策略。哪个政策应该适用于移动的资源,旧的还是新的新的?
Imagine we’re moving a resource from one parent to another, but the parents have different security and access control policies. Which policy should apply to the moved resource, the old or the new?
As much as we’d love to require permanence of resources, it’s very likely that users will need to duplicate or relocate resources in an API.
Rather than relying on standard methods (update and create) to relocate or duplicate resources, we should use custom move and copy methods instead.
Copy and move operations should also include the same operation on child resources; however, this behavior should be considered on a case-by-case basis and references to moved resources should be kept up-to-date.
When resources address external data, API methods should clarify whether a copied resource is copy by reference or copy by value (or copy on write).
Copy and move custom methods should be as atomic as reasonably possible given the limitations of the underlying storage system.
此模式提供了一些指南,API 的用户可以根据这些指南批量操作多个资源,而无需进行单独的 API 调用。这些所谓的批处理操作与标准方法(在第 7 章中讨论)的行为类似,但它们避免了多次 API 调用的多次来回交互的必要性。最终结果是一组方法,允许通过单个 API 调用而不是每个资源调用一个来检索、更新或删除资源集合。
This pattern provides guidelines by which users of an API can manipulate multiple resources in bulk without making separate API calls. These so-called batch operations behave similarly to the standard methods (discussed in chapter 7), but they avoid the necessity of multiple back-and-forth interactions of multiple API calls. The end result is a set of methods that permit retrieving, updating, or deleting a collection of resources with a single API call rather than one per resource.
到目前为止,我们探索的大多数设计模式和指南都集中在与单个资源的交互上。事实上,我们在第 8 章中更深入地探索了如何更窄地操作,专注于处理单个资源上的各个字段。虽然到目前为止这已被证明非常有用,但它在频谱的另一端留下了空白。如果我们想更广泛地同时跨多个资源运营怎么办?
So far, most of the design patterns and guidelines we’ve explored have been focused on interacting with individual resources. In fact, we’ve taken this even further in chapter 8 by exploring how to operate even more narrowly, focusing on addressing individual fields on a single resource. While this has proven quite useful thus far, it leaves a gap on the other end of the spectrum. What if we want to operate more broadly, across multiple resources simultaneously?
在典型的数据库系统中,我们解决这个问题的方法是使用事务。这意味着如果我们想同时对数据库中的多个不同行进行操作,我们只需开始一个事务,正常操作(但在事务的上下文中),然后提交事务。根据数据库的锁定功能和底层数据的易变性级别,事务可能成功或失败,但这里的关键是这种原子性。事务中包含的操作要么全部失败,要么全部成功;不会出现部分成功的情况,即某些操作成功执行而其他操作失败。
In a typical database system, the way we address this is by using transactions. This means that if we want to operate on multiple different rows in a database at the same time, we simply begin a transaction, operate as normal (but within the context of the transaction), and commit the transaction. Depending on the locking functionality of the database and the level of volatility of the underlying data, the transaction may succeed or fail, but the key takeaway here is this atomicity. The operations contained inside the transaction will either all fail or all succeed; there will be no partial success scenario where some operations execute successfully while others don’t.
不幸的是(也可能不是,取决于您正在与谁交谈),大多数 Web API 不提供这种通用事务功能 — 除非 API 用于事务存储服务。原因很简单:提供这种能力异常复杂。但是,这并不能消除 Web API 中对类似事务的语义的需求。一种常见的情况是,当 API 用户想要更新两个单独的资源但确实需要这两个独立的 API 请求要么都成功,要么都失败。例如,也许我们有一个ChatRoom可以启用或禁用的日志记录配置资源。我们不想在使用之前启用它,但我们也不想在启用之前将其作为默认日志记录配置。这种 catch-22 是事务语义存在的主要原因之一,因为没有它们,我们唯一的其他选择是尝试尽可能靠近地执行这两个独立的操作(启用配置并将其分配为默认值) .
Unfortunately (or not, depending on who you’re talking to), most web APIs don’t provide this generic transactional functionality—unless the API is for a transactional storage service. The reason for this is quite simple: it’s exceptionally complex to provide this ability. However, this doesn’t take away the need for transaction-like semantics in a web API. One common scenario is when an API user wants to update two separate resources but really needs these two independent API requests to either both succeed or both fail. For example, perhaps we have a ChatRoom logging configuration resource that can be enabled or disabled. We don’t want to enable it before it’s being used, but we don’t want to refer to it as the default logging configuration before it’s enabled. This sort of catch-22 is one of the primary reasons that transactional semantics exist, because without them, our only other option is to try to perform these two independent operations (enable the config and assign it as the default) as close together as possible.
显然这是不够的,但这引出了一个大问题:我们能做什么?我们如何在不像大多数关系数据库那样构建整个事务系统的情况下,在多个 API 请求中获得一些类似的原子性或事务语义?
Obviously this is insufficient, but that leads us to the big question: what can we do? How do we get some semblance of atomicity or transactional semantics across multiple API requests without building an entire transaction system like most relational databases have?
作为尽管开发一个成熟的通用事务设计模式可能会很有趣,但这肯定有点过分了。那么我们可以做些什么来让我们的时间发挥最大的价值呢?换句话说,我们可以削减哪些角落来减少所需的工作量,同时仍然支持以原子方式操作多个资源?
As much fun as it might be to develop a fully-fledged generic transaction design pattern, that is certainly a bit excessive. So what can we do that gives us the most value for our time? In other words, what corners can we cut to reduce the amount of work needed while still providing support for operating on multiple resources atomically?
在这个设计模式中,我们将探索如何通过指定几个自定义方法来提供这些事务语义的有限版本,类似于我们在第 7 章中看到的标准方法,这些方法允许对称为批处理的任意资源组进行原子操作. 这些方法的命名与标准方法非常相似(例如,BatchDeleteMessages) 但它们的实施会有所不同,具体取决于对批次执行的标准方法。
In this design pattern, we’ll explore how to provide a limited version of these transactional semantics by specifying several custom methods, analogous to the standard methods we saw in chapter 7, that permit atomic operation on arbitrary groups of resources called batches. These methods will be named quite similarly to the standard methods (e.g., BatchDeleteMessages) but will vary in their implementation depending on the standard method being performed on the batch.
Listing 18.1 Example of a batch Delete method
abstract class ChatRoomApi { @post("/{parent=chatRooms/*}/messages:batchDelete") BatchDeleteMessages(req: BatchDeleteMessagesRequest): void; }
与大多数这样的提案一样,它会在我们开始工作之前引发很多问题。例如,我们应该为这些方法中的每一个使用什么 HTTP 方法?是不是应该一直这样POST根据自定义方法的要求(参见第 9 章)?或者它应该与每个标准方法的 HTTP 方法相匹配(例如,GET对于BatchGetMessages和DELETE对于BatchDeleteMessages)? 还是取决于方法?
As with most proposals like this, it leads to quite a few questions before we can get to work. For example, what HTTP method should we use for each of these methods? Should it always be POST as required for custom methods (see chapter 9)? Or should it match with the HTTP method for each of the standard methods (e.g., GET for BatchGetMessages and DELETE for BatchDeleteMessages)? Or does it depend on the method?
我们对原子性的要求有多极端?如果我尝试检索一批Message资源使用BatchGetMessages其中一个资源已被删除,我真的必须让整个操作失败吗?还是可以直接跳过它并返回当时仍然存在的那些?对跨多个父资源的资源进行操作怎么样?换句话说,是否可以删除Message资源从多个ChatRooms?
And how extreme do we get with our atomicity requirements? If I attempt to retrieve a batch of Message resources using BatchGetMessages and one of these resources has since been deleted, do I really have to fail the entire operation? Or is it acceptable to skip right past that and return those that did still exist at the time? What about operating on resources across multiple parent resources? In other words, should it be possible to delete Message resources from multiple ChatRooms?
在此模式中,我们将探讨如何定义这些批处理方法、它们如何工作,以及在应用程序接口。
In this pattern, we’ll explore how to define these batch methods, how they work, and all of these curious edge cases that we’ll inevitably run into when building out this functionality in an API.
作为我们刚刚在 18.2 节中了解到,我们将依靠专门命名的自定义方法来支持这些批处理操作。这些方法只是标准方法的批处理版本,有一个例外:标准列表方法没有批处理版本。这导致以下批量自定义方法:
As we just learned in section 18.2, we will rely on specially named custom methods to support these batch operations. These methods are simply batch versions of the standard methods, with one exception: there is no batch version of the standard list method. This leads to the following batch custom methods:
虽然我们将在后面的部分中分别探讨每个方法的详细信息,但肯定值得研究每个方法的一些共同方面,从最重要的方面开始:原子性。
While we’ll explore the details of each method individually in later sections, it’s certainly worth going through some of the aspects that are common to each, starting with the most important: atomicity.
什么时候我们说一组操作是原子的,我们的意思是这些操作彼此不可分割。到目前为止,我们讨论的所有操作本身都被认为是原子的。毕竟,您不能让标准创建方法部分失败。资源要么创建,要么不创建,但没有中间立场。不幸的是,我们将标准方法设计为单独的原子性的方式并不能共同提供相同的保证。这些批处理操作的目标是将相同的原子性原则扩展到在多个资源上执行的方法,而不仅仅是一个资源。
When we say a set of operations is atomic we mean that these operations are indivisible from one another. So far, all operations we’ve discussed have been themselves considered atomic. After all, you cannot have a standard create method fail partially. The resource is either created or it’s not, but there’s no middle ground. Unfortunately, the way we’ve designed the standard methods to be atomic individually simply doesn’t provide the same guarantee collectively. The goal of these batch operations is to extend that same principle of atomicity to methods performed on multiple resources rather than just a single one.
达到这个目标意味着动作必须是原子的,即使它可能有点不方便。例如,假设我们正在尝试使用一种方法检索一批Message资源BatchGetMessages. 如果检索这些资源中的任何一个都碰巧导致错误,那么整个请求一定会导致错误。无论是 5 分之一导致错误还是 10,000 分之一,都必须是这种情况。
Meeting this goal means that the action must be atomic even when it might be a bit inconvenient. For example, imagine that we’re trying to retrieve a batch of Message resources using a BatchGetMessages method. If retrieving even one of these resources happens to result in an error, then the entire request must result in an error. This must be the case whether it’s 1 in 5 resulting in an error or 1 in 10,000.
这样做的原因有两个。首先,在很多情况下(比如更新成批的资源),原子性是整个操作的重点。我们积极地希望确保要么应用所有更改,要么不应用任何更改。虽然这不完全是批量检索的场景(我们可以为可能不存在的项目取回一个null值),但它导致了第二个原因。如果我们必须支持部分成功,那么管理结果的界面可能会变得相当错综复杂。相反,通过确保完全成功或单个错误,接口始终面向完全成功,并简单地返回批处理中涉及的资源列表。
The reason for this is two-fold. First, in many cases (like updating batches of resources), atomicity is the entire point of the operation. We actively want to be sure that either all changes are applied or none of them. While this isn’t exactly the scenario with batch retrieval (we might be okay to get back a null value for items that might not exist), it leads to the second reason. If we have to support partial success, the interface to managing the results can get pretty intricate and complex. Instead, by ensuring either complete success or a single error, the interface is always geared toward complete success and simply returns the list of resources involved in the batch.
因此,我们将在此模式中探索的所有批处理方法始终是完全原子的。响应消息将被设计成它们只能完全成功或完全失败;永远不会有中间地面。
As a result, all the batch methods that we’ll explore in this pattern will always be completely atomic. The response messages will be designed such that they can only ever completely succeed or completely fail; there will never be a middle ground.
作为我们在 9.3.2 节中了解到,当谈到与集合交互的自定义方法时,我们有两种不同的方法 URL 格式选项。我们可以将父资源本身作为目标并让自定义方法名称包含它与之交互的资源,或者我们可以将自定义操作部分保留为动词并对集合本身进行操作。表 18.1 总结了这两个选项。
As we learned in section 9.3.2, when it comes to custom methods that interact with collections, we have two different options for the URL format for the method. We can either target the parent resource itself and have the custom method name include the resources it’s interacting with, or we can leave the custom action section as a verb and operate on the collection itself. These two options are summarized in table 18.1.
Table 18.1 Options of targets and corresponding URLs for batch methods
第 9 章所指出的在今天仍然适用。每当我们处理一个集合,其中我们有多个相同类型的资源时,对集合本身进行操作几乎总是更好。这适用于我们将详细讨论的所有批处理方法:它们都以集合为目标,因此 URL 以结尾在:batch<Method>。
What was pointed out in chapter 9 holds true today. Whenever we’re dealing with a collection where we have multiple resources of the same type, it’s almost always better to operate on the collection itself. This holds true for all of the batch methods we’ll discuss in detail: they’ll all target the collection and therefore have URLs ending in :batch<Method>.
什么时候我们对任意一组资源进行操作,我们需要以某种请求的形式发送它们。在某些情况下,这可能只是一个唯一标识符列表(例如,当检索或删除资源时),但在其他情况下,它需要是资源本身(例如,当创建或更新资源时)。但在所有这些情况下,保留资源的顺序将变得越来越重要。
When we operate on an arbitrary group of resources, we’ll need to send them in a request of sorts. In some cases this might just be a list of unique identifiers (e.g., when retrieving or deleting resources), but in others it will need to be the resources themselves (e.g., when creating or updating resources). In all of these cases though, it’s going to become successively more important that the order of the resources be preserved.
为什么保持顺序很重要的最明显的例子是当我们创建一批新资源而不是自己选择标识符但允许服务这样做时。由于我们没有预先安排的方法来识别这些新创建的资源,最简单的方法是在提供给请求的重复资源字段中使用它们的索引。如果我们不保留顺序,我们将不得不对我们提供的资源和从批处理创建返回的资源进行深入比较,以匹配创建的项目及其服务器分配的标识符。
The most obvious example of why it’s important to preserve order is when we’re creating a batch of new resources and not choosing the identifiers ourselves but allowing the service to do so. Since we have no prearranged way of identifying these newly created resources, the simplest way is to use their index in the repeated field of resources that were provided to the request. If we don’t preserve the order, we’ll have to do a deep comparison between the resources we provided and those returned from the batch creation to match up the items created with their server-assigned identifiers.
Listing 18.2 Example code to match request resources
let chatRoom1 = ChatRoom({ title: "Chat 1", description: "Chat 1" }); let chatRoom2 = ChatRoom({ title: "Chat 2", description: "Chat 2"}); let results = BatchCreateChatRooms({ resources: [chatRoom1, chatRoom2] }); chatRoom1 = results.resources[0]; ❶ chatRoom2 = results.resources[1]; for (let resource of results.resources) { if (deepCompare(chatRoom1, resource)) ❷ chatRoom1 = resource; else if (deepCompare(chatRoom2, resource)) chatRoom2 = resource; } }
❶在我们知道顺序的情况下,我们可以轻松地将结果与请求的操作相关联。
❶ In the case where we know the order, we can easily associate the results with the requested action.
❷当我们不知道顺序时,我们必须对资源的每个字段(ID 字段除外)的相等性进行全面比较,以确保我们拥有正确的资源。
❷ When we don’t know the order, we have to do a full comparison of the resources for equality of each of their fields (except the ID field) to be sure we have the right resource.
因此,一个重要的要求是,当批处理方法返回批处理中涉及的资源时,它们将以与它们返回时相同的顺序返回假如。
As a result, it’s an important requirement that when a batch method returns the resources involved in a batch they are returned in the same order in which they were provided.
什么时候谈到批处理操作,我们有两种主要策略可供选择。第一个更简单的选项是将请求的批处理版本仅视为普通的单资源请求列表。第二个选项有点复杂,但可以通过提升与请求相关的字段并简单地重复这些字段来减少重复。
When it comes to batch operations, we have two primary strategies to choose from. The first, simpler option is to treat the batch version of a request as just a list of normal, single-resource requests. The second option is a bit more complicated but can reduce duplication by hoisting up the fields that are relevant to the request and simply repeat those.
Listing 18.3 Strategies of hoisting fields and relying on repeated requests
interface GetMessageRequest { id: string; } interface BatchGetMessagesRequest { requests: GetMessageRequest[]; ❶ } interface BatchGetMessagesRequest { ids: string[]; ❷ }
❶这里我们简单地有一个 GetMessageRequest 接口的列表。
❶ Here we simply have a list of GetMessageRequest interfaces.
❷在这里,我们将相关字段 (id) 提升出来,并将其作为一个批次的 ID 列表嵌入。
❷ Here we hoist out the relevant field (id) and embed it as a list of IDs for a batch.
事实证明,我们需要依赖这两种策略,具体取决于所讨论的方法,因为更简单的策略(提供请求列表)更适合我们需要大量定制的情况(例如,如果我们需要创建可能具有不同父级的资源),而从请求中提升字段的策略更适合更简单的操作,例如检索或删除资源,因为不需要额外的信息。
It turns out that we’ll need to rely on both strategies, depending on the method in question, because the simpler strategy (providing a list of requests) is a much better fit for cases where we need quite a bit of customization (e.g., if we need to create resources that might have different parents), while the strategy of hoisting fields out of the request is a much better fit for simpler actions such as retrieving or deleting resources, as no additional information is necessary.
此外,这些策略不一定相互排斥。在某些情况下,我们实际上会提升一些值,同时仍然依赖于使用请求列表来传达重要信息的策略。一个很好的例子是更新一批资源,我们将依赖重复的更新请求列表,但也会提升父字段和潜在的字段掩码来处理部分批量更新。
Additionally, these strategies are not necessarily mutually exclusive. In some cases, we will actually hoist some values while still relying on the strategy of using a list of requests to communicate the important information. A good example of this is updating a batch of resources, where we will rely on a repeated list of update requests but also hoist the parent field and potentially the field mask to handle partial batch updates.
虽然这种策略组合听起来不错,但它引出了一个重要问题:当提升的字段与任何单个请求上设置的字段不同时会发生什么?换句话说,如果我们有一个提升的parent场怎么办设置为ChatRoom1 并且要创建的资源之一的parent字段设置为ChatRoom2?简短的回答遵循快速和非部分失败的理念:API 应该简单地抛出错误并拒绝请求。虽然将资源对提升字段的重新定义视为更具体的覆盖可能很诱人,但这里的代码很可能存在语义错误,并且对资源组进行操作当然不是尝试进行推断的时候关于用户意图。相反,如果用户打算在多个资源中改变提升字段,他们应该将提升字段留空或将其设置为通配符值,我们将在接下来讨论部分。
While this combination of strategies sounds great, it leads to an important question: what happens when a hoisted field is different from the field set on any of the individual requests? In other words, what if we have a hoisted parent field set to ChatRoom 1 and one of the to-be-created resources has its parent field set to ChatRoom 2? The short answer keeps with the philosophy of fast and nonpartial failure: the API should simply throw an error and reject the request. While it might be tempting to treat the resource’s redefinition of the hoisted field as a more specific override, it’s quite possible that there is a semantic mistake in the code here, and operating on groups of resources is certainly not the time to try to make inferences about user intentions. Instead, if a user intends to vary the hoisted field across multiple resources, they should leave the hoisted field blank or set it to a wild card value, which we’ll discuss in the next section.
一依赖单个请求列表而不是将字段向上提升到批请求中的最常见原因是对可能也属于多个不同父级的多个资源进行操作。例如,清单 18.4 显示了一个选项来定义一个示例BatchCreateMessages方法,它支持跨多个不同的父级创建资源,但这样做的方式不寻常(且不正确)。
One of the most common reasons for relying on a list of individual requests rather than hoisting fields upward into the batch request is to operate on multiple resources that might also belong to multiple different parents. For example, listing 18.4 shows one option to define an example BatchCreateMessages method, which supports creating resources across multiple different parents, but doing so in an unusual (and incorrect) way.
Listing 18.4 Incorrect way of supporting multi-parent creation in a batch method
interface Message { id: string; title: string; description: string; } interface CreateMessageRequest { parent: string; resource: Message; } interface BatchCreateMessageRequest { parents: string[]; ❶ resources: Message[]; ❷ }
❶ We need a list of the parents to which these Message resources will belong.
❷这里我们从 CreateMessageRequest 接口提升了 Message 资源。
❷ Here we’ve hoisted the Message resources from the CreateMessageRequest interface.
如您所见,它依赖于要创建的资源列表,但我们还需要知道每个资源的父级。由于资源本身不存在该字段,我们现在需要一种方法来跟踪每个资源的父级。在这里,我们通过拥有第二个父母名单来做到这一点。虽然这在技术上确实有效,但由于严格依赖于维护两个列表中的顺序,因此使用起来可能非常笨拙。我们如何处理这种跨父批处理操作?
As you can see, it relies on a list of the resources to be created, but we also need to know the parent of each of these. And since that field doesn’t exist on the resource itself, we now need a way of keeping track of each resource’s parent. Here, we’ve done this by having a second list of parents. While this does technically work, it can be quite clumsy to work with due to the strict dependence on maintaining order in the two lists. How do we handle this cross-parent batch operation?
简单的答案是依靠父级的通配符值并允许父级本身在请求列表中变化。在这种情况下,我们会将连字符 ( -) 标准化为通配符,以表示“跨多个父级”。
The simple answer is to rely on a wild card value for the parent and allow the parent itself to vary inside the list of requests. In this case, we’ll standardize the hyphen character (-) as the wild card to indicate “across multiple parents.”
Listing 18.5 HTTP request to create two resources with different parents
POST /chatRooms/-/messages:batchCreate HTTP/1.1 ❶ Content-Type: application/json { "requests": [ { "parent": "chatRooms/1", ❷ "resource": { ... } }, { "parent": "chatRooms/2", ❷ "resource": { ... } } ] }
❶ We rely on a wild card hyphen character to indicate multiple parents, to be defined in the requests.
❷ We define the actual parent in the list of requests to create the resources.
对于可能需要跨属于多个父级的资源进行操作的所有其他批处理方法,应该使用相同的机制。然而,就像提升字段一样,这引发了一个有关值冲突的问题:如果在 URL 中指定了单个父级,但某些请求级别的父级字段发生冲突怎么办?应该做什么?
This same mechanism should be used for all the other batch methods that might need to operate across resources belonging to multiple parents. However, just like with the hoisted fields, this raises a question about conflicting values: what if a single parent is specified in the URL, yet some of the request-level parent fields conflict? What should be done?
在这种情况下,API 的行为应与第 18.3.4 节中定义的完全一致:如果存在冲突,我们应将请求视为无效而拒绝,并等待用户以另一个 API 调用的形式进行澄清。这是出于与之前提到的完全相同的原因:用户的意图不明确,并且在(可能很大的)资源组上操作当然不是猜测这些意图可能是什么的时候。
In this case, the API should behave exactly as defined in section 18.3.4: if there is a conflict, we should reject the request as invalid and await clarification from the user in the form of another API call. This is for exactly the same reasons as noted before: the user’s intent is unclear, and operating on a (potentially large) group of resources is certainly not the time to guess what those intentions might be.
至此,我们终于可以进入最重要的部分:准确定义这些方法的外观以及它们各自的工作方式。让我们从最简单的一种开始:批处理得到。
With that, we can finally get to the good part: defining exactly what these methods look like and how each of them should work. Let’s start by looking at one of the simplest: batch get.
这batch get 方法的目标是通过提供一组唯一标识符从 API 中检索一组资源。由于这类似于标准的 get 方法,并且是幂等的,因此批量 get 将依赖 HTTPGET方法进行传输。然而,这会带来一些问题,因为GET方法通常没有请求主体。结果是标识符列表在查询字符串中提供,就像我们在第 8 章中看到的字段掩码一样。
The goal of the batch get method is to retrieve a set of resources from an API by providing a group of unique identifiers. Since this is analogous to the standard get method, and is idempotent, batch get will rely on the HTTP GET method for transport. This, however, presents a bit of a problem in that GET methods generally do not have a request body. The result is that the list of identifiers is provided in the query string like we saw with field masks in chapter 8.
Listing 18.6 Example of the batch get method
abstract class ChatRoomApi { @get("/chatrooms:batchGet") BatchGetChatRooms(req: BatchGetChatRoomsRequest): BatchGetChatRoomsResponse; @get("/{parent=chatRooms/*}/messages:batchGet") BatchGetMessages(req: BatchGetMessagesRequest): BatchGetMessagesResponse; } interface BatchGetChatRoomsRequest { ❶ ids: string[]; ❷ } interface BatchGetChatRoomsResponse { resources: ChatRoom[]; ❸ } interface BatchGetMessagesRequest { parent: string; ❹ ids: string[]; ❷ } interface BatchGetMessagesResponse { resources: Message[]; ❸ }
❶由于 ChatRoom 资源没有父资源,因此批量获取请求不提供指定父资源的地方。
❶ Since ChatRoom resources have no parent, the batch get request doesn’t provide a place to specify a parent.
❷ All batch get requests specify a list of IDs to retrieve.
❸ The response always includes a list of resources, in the exact same order as the IDs that were requested.
❹由于 Message 资源有父级,请求有一个地方可以指定父级(或提供通配符)。
❹ Since Message resources have parents, the request has a place to specify the parent (or provide a wild card).
如您所见,除非资源组是没有父资源的顶级资源,否则我们需要在请求消息中为父值指定一些内容。如果我们打算将检索限制为全部来自同一父级,那么我们有一个明显的答案:使用父级标识符本身。相反,如果我们想要提供跨多个不同父级检索资源的能力,我们应该依赖连字符作为通配符值,如我们在 18.3.5 节中看到的那样。
As you can see, unless the group of resources are top-level resources without a parent, we’ll need to specify something for the parent value in the request message. If we intend to limit the retrievals to be all from the same parent, then we have an obvious answer: use the parent identifier itself. If, instead, we want to provide the ability to retrieve resources across multiple different parents, we should rely on a hyphen character as a wild card value, as we saw in section 18.3.5.
尽管总是允许跨父检索可能很诱人,但请仔细考虑它对用例是否有意义。现在所有的数据可能都在一个数据库中,但将来存储系统可能会扩展并将数据分布到许多不同的地方。由于父资源是分布键的明显选择,并且分布式存储变得昂贵或难以在单个分布键的范围之外查询,因此提供跨父资源检索资源的能力可能变得异常昂贵或无法查询。在这些情况下,唯一的选择是不允许父代使用通配符,这几乎可以肯定是一个重大改变(见第 24 章)。请记住,正如我们在 18.3.5 节中看到的,如果列表中提供的 ID 会与明确指定的父级(即。
Although it might be tempting to always permit cross-parent retrievals, think carefully about whether it makes sense for the use case. All the data might be in a single database now, but in the future the storage system may expand and distribute data to many different places. Since a parent resource is an obvious choice for a distribution key, and the distributed storage becomes expensive or difficult to query outside the scope of a single distribution key, providing the ability to retrieve resources across parents could become exceptionally expensive or impossible to query. In those cases, the only option will be to disallow the wildcard character for a parent, which is almost certainly a breaking change (see chapter 24). And remember, as we saw in section 18.3.5, if an ID provided in the list would conflict with an explicitly specified parent (i.e., any parent value that isn’t a wildcard), the request should be rejected as invalid.
接下来,正如我们在 18.3.1 节中了解到的,此方法必须是完全原子的,这一点很关键,即使这样做很不方便。这意味着即使 100 个 ID 中只有 1 个出于任何原因无效(例如,它不存在或请求用户无权访问它),它也必须完全失败。在您寻求不太严格的保证的情况下,最好依靠标准列表方法,并应用过滤器来匹配一组可能的标识符中的一个(有关这方面的更多信息,请参见第 22 章)。
Next, as we learned in section 18.3.1, it’s critical that this method be completely atomic, even when it’s inconvenient. This means that even when just 1 out of 100 IDs is invalid for any reason (e.g., it doesn’t exist or the requesting user doesn’t have access to it), it must fail completely. In cases where you’re looking for less strict guarantees, it’s far better to rely on the standard list method with a filter applied to match on one of a set of possible identifiers (see chapter 22 for more information on this).
此外,正如我们在 18.3.4 节中了解到的,如果我们想要支持资源的部分检索,例如只从指定的每个 ID 请求一个字段,我们可以提升字段掩码,就像我们对父字段所做的那样. 这个单一的字段掩码应该应用于所有检索到的资源。这不应该是字段掩码列表,以便将不同的字段掩码应用于指定的每个资源。
Additionally, as we learned in section 18.3.4, if we want to support partial retrieval of the resources, such as requesting only a single field from each of the IDs specified, we can hoist the field mask just as we did with the parent field. This single field mask should be applied across all resources retrieved. This should not be a list of field masks in order to apply a different field mask to each of the resources specified.
Listing 18.7 Adding support for partial retrieval in a batch get request
interface BatchGetMessagesRequest { parent: string; ids: string[]; fieldMask: FieldMask; ❶ }
❶要启用部分检索,批处理请求应包含一个字段掩码以应用于所有检索到的资源。
❶ To enable partial retrievals, the batch request should include a single field mask to be applied to all retrieved resources.
最后,值得注意的是尽管对这个请求的响应可能会变得相当大,批处理方法不应该实现分页(参见第 21 章)。相反,批处理方法应该定义并记录可以检索的资源数量的上限。此限制可能因每个单独资源的大小而异,但通常应选择以尽量减少可能导致 API 服务器性能下降或 API 响应大小过大的过大响应的可能性客户。
Finally, it’s worth noting that despite the fact that the response to this request could become quite large, batch methods should not implement pagination (see chapter 21). Instead, batch methods should define and document an upper limit on the number of resources that may be retrieved. This limit can vary depending on the size of each individual resource, but generally should be chosen to minimize the possibility of excessively large responses that can cause performance degradation on the API server or unwieldy response sizes for the API clients.
在与批量获取操作类似,批量删除也对标识符列表进行操作,尽管最终目标非常不同。它不是检索所有这些资源,而是删除每一个资源。此操作将依赖于 HTTPPOST方法,遵循我们在第 9 章中探索的自定义方法的指南。并且由于请求必须是原子的,删除列出的所有资源或返回错误,最终返回类型是void,代表一个空的结果。
In a similar manner to the batch get operation, batch delete also operates on a list of identifiers, though with a very different end goal. Rather than retrieving all of these resources, its job is to delete each and every one of them. This operation will rely on an HTTP POST method, adhering to the guidelines for custom methods that we explored in chapter 9. And since the request must be atomic, deleting all the resources listed or returning an error, the final return type is void, representing an empty result.
Listing 18.8 Example of the batch delete method
abstract class ChatRoomApi { @post("/chatrooms:batchDelete") BatchDeleteChatRooms(req: BatchDeleteChatRoomsRequest): void; @post("/{parent=chatRooms/*}/messages:batchDelete") BatchDeleteMessages(req: BatchDeleteMessagesRequest): void; } interface BatchDeleteChatRoomsRequest { ❶ ids: string[]; ❷ } interface BatchDeleteMessagesRequest { parent: string; ❸ ids: string[]; ❷ }
❶ For top-level resources, no parent field is provided on requests.
❷就像批量获取一样,批量删除操作接受标识符列表而不是任何资源本身。
❷ Just like batch get, batch delete operations accept a list of identifiers rather than any resources themselves.
❸由于 Message 资源不是顶级的,我们需要一个字段来保存该父值。
❸ Since Message resources are not top-level, we need a field to hold that parent value.
在大多数方面,此方法与我们刚刚在 18.3.6 节中介绍的批量获取方法非常相似。例如,顶级资源可能没有父字段,但其他资源有。并且指定parent字段时,必须匹配提供ID的资源的parent,否则返回错误结果。此外,就像批量获取操作的情况一样,可以通过对父字段使用通配符来删除多个不同的父字段;但是,由于分布式存储系统的潜在问题,应该仔细考虑。
In most aspects, this method is quite similar to the batch get method we just went through in section 18.3.6. For example, top-level resources might not have a parent field, but others do. And when the parent field is specified, it must match the parent of the resources whose IDs are provided or return an error result. Further, just as is the case for batch get operations, deleting across several different parents is possible by using a wild card character for the parent field; however, it should be considered carefully due to the potential issues down the line with distributed storage systems.
一个值得再次特别指出的领域是原子性:批量删除操作必须删除列出的所有资源或完全失败。这意味着如果 100 个资源中有 1 个由于任何原因无法删除,则整个操作必须失败。这包括资源已被删除且不再存在的情况,这首先是操作的实际意图。这样做的原因,在第 7 章中有更详细的探讨,是因为我们需要对删除操作采取命令式的观点,而不是声明式的。这意味着我们必须能够说,不仅我们要删除的资源确实不再存在,而且由于执行了这个特定操作而不是由于更早时间点的其他操作,它不再存在。
One area worth calling out specifically again is that of atomicity: batch delete operations must either delete all the resources listed or fail entirely. This means that if 1 resource out of 100 is unable to be deleted for any reason, the entire operation must fail. This includes the case where the resource has already been deleted and no longer exists, which was the actual intent of the operation in the first place. The reason for this, explored in more detail in chapter 7, is that we need to take an imperative view of the delete operation rather than a declarative one. This means that we must be able to say not just that the resource we wanted deleted indeed no longer exists, but that it no longer exists due to the execution of this specific operation and not due to some other operation at an earlier point in time.
现在我们已经介绍了基于 ID 的操作,让我们进入更复杂的场景,从批量创建开始操作。
Now that we’ve covered the ID-based operations, let’s get into the more complicated scenarios, starting with batch create operations.
作为与所有其他批处理操作一样,批处理创建操作的目的是创建多个以前不存在的资源,并且以原子方式进行。与我们已经探索过的更简单的方法(批量获取和批量删除)不同,批量创建将依赖于接受标准创建请求列表以及提升的父字段的更复杂的策略。
As with all the other batch operations, the purpose of a batch create operation is to create several resources that previously didn’t exist before—and to do so atomically. Unlike the simpler methods we’ve explored already (batch get and batch delete) batch create will rely on a more complicated strategy of accepting a list of standard create requests alongside a hoisted parent field.
Listing 18.9 Example of the batch create method
abstract class ChatRoomApi { @post("/chatrooms:batchCreate") BatchCreateChatRooms(req: BatchCreateChatRoomsRequest): BatchCreateChatRoomsResponse; @post("/{parent=chatRooms/*}/messages:batchCreate") BatchCreateMessages(req: BatchCreateMessagesRequest): BatchCreateMessagesResponse; } interface CreateChatRoomRequest { resource: ChatRoom; } interface CreateMessageRequest { parent: string; resource: Message; } interface BatchCreateChatRoomsRequest { requests: CreateChatRoomRequest[]; ❶ } interface BatchCreateMessagesRequest { parent: string; ❷ requests: CreateMessageRequest[]; ❶ } interface BatchCreateChatRoomsResponse { resources: ChatRoom[]; } interface BatchCreateMessagesResponse { resources: Message[]; }
❶ Rather than a list of resources or IDs, we rely on a list of standard create requests.
❷ When the resource is not top-level, we also include the parent (which may be a wild card).
与其他批处理方法类似,父字段仅与非顶级资源相关(在这种情况下,ChatRoom资源) 并且限制保持不变(如果提供了非通配符父级,则它必须与正在创建的所有资源一致)。主要区别在于传入数据的形式。在这种情况下,我们不是将字段从标准创建请求中拉出并直接放入批创建请求中,而是直接包含标准创建请求。
Similar to the other batch methods, the parent fields are only relevant for non-top-level resources (in this case, the ChatRoom resource) and the restrictions remain the same (that if a non-wild card parent is provided, it must be consistent with all the resources being created). The primary difference is in the form of the incoming data. In this case, rather than pulling the fields out of the standard create request and directly into the batch create request, we simply include the standard create requests directly.
这可能看起来不寻常,但有一个非常重要的原因,它与父母有关。简而言之,我们希望提供以原子方式创建可能具有多个不同父级的多个资源的能力。不幸的是,资源的父资源在创建时通常不会作为字段直接存储在资源本身上。相反,它通常在标准创建请求中提供,从而产生一个包含父资源作为根的标识符(例如,chatRooms/1/messages/2)。这个限制意味着如果我们想要允许创建跨父资源而不需要客户端生成的标识符,我们需要提供资源本身及其预期的父资源,这正是标准创建请求接口所做的。
This might seem unusual, but there’s a pretty important reason and it has to do with the parent. In short, we want to provide the ability to create multiple resources atomically that might have several different parents. Unfortunately, the parent of a resource isn’t typically stored as a field directly on the resource itself when it’s being created. Instead, it’s often provided in the standard create request, resulting in an identifier that includes the parent resource as the root (e.g., chatRooms/1/messages/2). This restriction means that if we want to allow cross-parent resource creation without requiring client-generated identifiers, we need to provide both the resource itself along with its intended parent, which is exactly what the standard create request interface does.
与其他批处理方法的另一个相似之处是排序约束。就像其他方法一样,新创建的资源列表绝对必须与它们在批处理请求中提供的顺序完全相同。虽然这在大多数其他批处理方法中很重要,但在批创建的情况下更是如此,因为与其他情况不同,正在创建的资源的标识符可能尚不存在,这意味着很难发现如果返回的顺序与发送顺序不同,则创建资源的新标识符。
Another similarity to the other batch methods is the ordering constraint. Just like other methods, the list of newly created resources absolutely must be in the exact same order as they were provided in the batch request. While this is important in most other batch methods, it is even more so in the case of batch create because, unlike the other cases, the identifiers for the resources being created may not yet exist, meaning that it can be quite difficult to discover the new identifiers for the resources that were created if they are returned in a different order than the one in which they were sent.
最后,让我们通过查看批量更新方法来总结一下,它与批量创建非常相似方法。
Finally, let’s wrap up by looking at the batch update method, which is quite similar to the batch create method.
这批量更新方法的目标是在一个原子操作中一起修改一组资源。正如我们在批量创建方法中看到的那样,批量更新将依赖于标准更新请求列表作为输入。这种情况下的不同之处在于,需要请求列表而不是资源列表的理由应该非常明显:部分更新。
The goal of the batch update method is to modify a group of resources together in one atomic operation. And just as we saw with the batch create method, batch update will rely on a list of standard update requests as the input. The difference in this case is that the rationale for needing a list of requests rather than a list of resources should be quite obvious: partial updates.
在更新资源的情况下,我们可能希望控制要更新的特定字段,并且很常见的是,不同的字段可能需要在批处理中对不同的资源进行更新。因此,我们可以像在设计批量创建方法时对待父字段一样对待字段掩码。这意味着我们希望能够将不同的字段掩码应用于正在更新的不同资源,而且还希望能够在所有正在更新的资源中应用单一的一揽子字段掩码。显然这些不能冲突,这意味着如果批处理请求设置了字段掩码,则各个请求的字段掩码必须匹配或留空。
In the case of updating a resource, we might want to control the specific fields to be updated, and it’s quite common that different fields might need to be updated on different resources in the batch. As a result, we can treat the field mask similarly to how we treated the parent field when designing the batch create method. This means that we want the ability to apply different field masks to different resources being updated, but also the ability to apply a single blanket field mask across all the resources being updated. Obviously these must not conflict, meaning that if the batch request has a field mask set, the field masks on individual requests must either match or be left blank.
Listing 18.10 Example of the batch update method
abstract class ChatRoomApi { @post("/chatrooms:batchUpdate") ❶ BatchUpdateChatRooms(req: BatchUpdateChatRoomsRequest): BatchUpdateChatRoomsResponse; @post("/{parent=chatRooms/*}/messages:batchUpdate") ❶ BatchUpdateMessages(req: BatchUpdateMessagesRequest): BatchUpdateMessagesResponse; } interface UpdateChatRoomRequest { resource: ChatRoom; fieldMask: FieldMask; } interface UpdateMessageRequest { resource: Message; fieldMask: FieldMask; } interface BatchUpdateChatRoomsRequest { requests: UpdateChatRoomRequest[]; ❷ fieldMask: FieldMask; ❸ } interface BatchUpdateMessagesRequest { parent: string; ❷ requests: UpdateMessageRequest[]; ❸ fieldMask: FieldMask; } interface BatchUpdateChatRoomsResponse { resources: ChatRoom[]; } interface BatchUpdateMessagesResponse { resources: Message[]; }
❶尽管标准更新方法使用 HTTP PATCH 方法,但我们依赖 HTTP POST 方法。
❶ Despite the standard update method using the HTTP PATCH method, we rely on the HTTP POST method.
❷和批量创建方法一样,我们使用的是请求列表,而不是资源列表。
❷ Just like the batch create method, we use a list of requests rather than a list of resources.
❸如果所有资源都相同,我们可以将部分更新的字段掩码提升到批处理请求中。
❸ We can hoist the field mask for partial updates into the batch request if it’s the same across all resources.
还值得一提的是,即使我们使用 HTTPPATCH方法在标准更新方法中,为了避免任何潜在的冲突(和破坏任何标准),批量更新方法应该使用 HTTPPOST方法与所有其他习俗一样方法。
It’s also worth mentioning that even though we use the HTTP PATCH method in the standard update method, to avoid any potential conflicts (and from breaking any standards) the batch update method should use the HTTP POST method as is done for all other custom methods.
和最后,我们可以通过查看示例聊天室 API 中支持批处理方法的最终 API 定义来结束。清单 18.11 显示了我们如何将所有这些批处理方法放在一起以支持广泛的功能,使用单个方法对潜在的大型资源组进行操作应用程序接口电话。
With that, we can wrap up by looking at the final API definition for supporting batch methods in our example chat room API. Listing 18.11 shows how we might put all of these batch methods together to support a wide range of functionality, operating on potentially large groups of resources with single API calls.
Listing 18.11 Final API definition
abstract class ChatRoomApi { @post("/chatrooms:batchCreate") BatchCreateChatRooms(req: BatchCreateChatRoomsRequest): BatchCreateChatRoomsResponse; @post("/{parent=chatRooms/*}/messages:batchCreate") BatchCreateMessages(req: BatchCreateMessagesRequest): BatchCreateMessagesResponse; @get("/chatrooms:batchGet") BatchGetChatRooms(req: BatchGetChatRoomsRequest): BatchGetChatRoomsResponse; @get("/{parent=chatRooms/*}/messages:batchGet") BatchGetMessages(req: BatchGetMessagesRequest): BatchGetMessagesResponse; @post("/chatrooms:batchUpdate") BatchUpdateChatRooms(req: BatchUpdateChatRoomsRequest): BatchUpdateChatRoomsResponse; @post("/{parent=chatRooms/*}/messages:batchUpdate") BatchUpdateMessages(req: BatchUpdateMessagesRequest): BatchUpdateMessagesResponse; @post("/chatrooms:batchDelete") BatchDeleteChatRooms(req: BatchDeleteChatRoomsRequest): void; @post("/{parent=chatRooms/*}/messages:batchDelete") BatchDeleteMessages(req: BatchDeleteMessagesRequest): void; } interface CreateChatRoomRequest { resource: ChatRoom; } interface CreateMessageRequest { parent: string; resource: Message; } interface BatchCreateChatRoomsRequest { requests: CreateChatRoomRequest[]; } interface BatchCreateMessagesRequest { parent: string; requests: CreateMessageRequest[]; } interface BatchCreateChatRoomsResponse { resources: ChatRoom[]; } interface BatchCreateMessagesResponse { resources: Message[]; } interface BatchGetChatRoomsRequest { ids: string[]; } interface BatchGetChatRoomsResponse { resources: ChatRoom[]; } interface BatchGetMessagesRequest { parent: string; ids: string[]; } interface BatchGetMessagesResponse { resources: Message[]; } interface UpdateChatRoomRequest { resource: ChatRoom; fieldMask: FieldMask; } interface UpdateMessageRequest { resource: Message; fieldMask: FieldMask; } interface BatchUpdateChatRoomsRequest { parent: string; requests: UpdateChatRoomRequest[]; fieldMask: FieldMask; } interface BatchUpdateMessagesRequest { requests: UpdateMessageRequest[]; fieldMask: FieldMask; } interface BatchUpdateChatRoomsResponse { resources: ChatRoom[]; } interface BatchUpdateMessagesResponse { resources: Message[]; } interface BatchDeleteChatRoomsRequest { ids: string[]; } interface BatchDeleteMessagesRequest { parent: string; ids: string[]; }
为了在这些不同的批处理方法中,我们做出了很多非常具体的决定,这些决定对生成的 API 行为有一些非常重要的影响。首先,所有这些方法都将原子性放在首位,即使它可能有点不方便。例如,如果我们尝试删除多个资源,其中一个已经被删除,那么整个批量删除方法就会失败。这是一个重要的权衡,可以避免处理支持返回结果的 API,这些结果显示一些成功和一些失败,而是专注于坚持大多数现代数据库系统中的事务语义行为。虽然这可能会导致常见场景的烦恼,但它确保 API 方法尽可能一致和简单,同时仍然提供一些重要的功能。
For many of these different batch methods, we made quite a few very specific decisions that have some pretty important effects on the resulting API behavior. First, all of these methods put atomicity above all else, even when it might be a bit inconvenient. For example, if we attempt to delete multiple resources and one of them is already deleted, then the entire batch delete method will fail. This was an important trade-off to avoid dealing with an API that supports returning results that show some pieces succeeding and some failing, and instead focus on sticking to the behavior of transactional semantics in most modern database systems. While this might result in annoyances for common scenarios, it ensures that the API methods are as consistent and simple as possible while still providing an important bit of functionality.
接下来,虽然输入数据的格式可能存在一些不一致(有时是原始 ID;有时是标准请求接口),但这些方法的设计强调简单性而不是一致性。例如,我们可以坚持所有批处理方法的重复标准请求列表;然而,其中一些方法(特别是批量获取和批量删除)将包含不必要的间接级别,只是为了提供标识符。通过将这些值提升到批处理请求中,我们以合理数量的代价获得更简单的体验不一致。
Next, while there may be some inconsistencies in the format of the input data (sometimes its raw IDs; other times it’s the standard request interfaces), the design of these methods emphasizes simplicity over consistency. For example, we could stick to a repeated list of standard requests for all batch methods; however, some of these methods (in particular, batch get and batch delete) would contain a needless level of indirection just to provide the identifiers. By hoisting these values into the batch request, we get a simpler experience at the cost of a reasonable amount of inconsistency.
Why is it important for results of batch methods to be in a specific order? What happens if they’re out of order?
In a batch update request, what should the response be if the parent field on the request doesn’t match the parent field on one of the resources being updated?
Why is it important for batch requests to be atomic? What would the API definition look like if some requests could succeed while others could fail?
Why does the batch delete method rely on the HTTP POST verb rather than the HTTP DELETE verb?
批处理方法应该按照 的格式命名Batch<Method> <Resources>()并且应该是完全原子的,执行批处理中的所有操作或不执行任何操作。
Batch methods should be named according to the format of Batch<Method> <Resources>() and should be completely atomic, performing all operations in the batch or none of them.
对同一类型的多个资源进行操作的批处理方法通常应以集合而不是父资源为目标(例如,POST /chatRooms/1234/messages:batchUpdate而不是POST /chatRooms/1234:batchUpdateMessages)。
Batch methods that operate on multiple resources of the same type should generally target the collection rather than a parent resource (e.g., POST /chatRooms/1234/messages:batchUpdate rather than POST /chatRooms/1234:batchUpdateMessages).
Results from batch operations should be in the same order as the resources or requests were originally sent.
Use a wild card hyphen character to indicate multiple parents of a resource to be defined in the individual requests.
虽然我们在第 18 章中了解的批处理操作提供了通过单个 API 调用删除多个资源的能力,但我们必须提前了解一个基本要求:我们要删除的资源的唯一标识符。但是,在许多情况下,我们对删除特定的资源列表不太感兴趣,而是对删除碰巧符合一组特定条件的任何资源更感兴趣。这种设计模式提供了一种机制,通过这种机制我们可以安全地、原子地删除所有符合特定条件的资源,而不是通过标识符列表。
While the batch operations we learned about in chapter 18 provide the ability to delete several resources with a single API call, there’s an underlying requirement that we must know in advance: the unique identifiers of the resource we want to delete. However, there are many scenarios where we’re not so much interested in deleting a specific list of resources but instead are more interested in deleting any resources that happen to match a specific set of criteria. This design pattern provides a mechanism by which we can safely and atomically remove all resources matching certain criteria rather than by a list of identifiers.
从第 18 章可以明显看出,一次想要操作多个资源的情况并不少见。更具体地说,我们可能想要清除一组特定资源。然而,与其他批处理操作相比,删除是迄今为止最直接的,不需要其他信息即可执行操作:给定一个 ID,删除资源。
As is evident from chapter 18, it’s not all that uncommon to want to operate on more than one resource at a time. More specifically, we might want to clear out a set of specific resources. However, compared to the other batch operations, deletion is by far the most straightforward, requiring no other information to perform an action: given an ID, remove the resource.
这是一个很棒的功能,但它要求我们已经确切地知道我们要删除哪些资源。这意味着如果我们还不知道这些信息,我们首先必须进行调查。例如,假设我们要删除所有ChatRoom资源标记为已存档。为了实现这一点,我们需要发现哪些资源具有此特定设置,然后使用这些标识符通过批量删除方法删除资源。
This is a wonderful piece of functionality, but it requires that we already know exactly which resources we want to delete. This means that if we don’t yet know this information, we first have to investigate. For example, imagine we want to delete all ChatRoom resources that are flagged as archived. To make this happen, we need to discover which resources have this particular setting and then use those identifiers to remove the resources with the batch delete method.
Listing 19.1 Criteria-based deletion using standard list and batch delete methods
function deleteArchivedChatRooms(): void { const archivedRooms = ListChatRooms({ ❶ filter: "archived: true" }); return BatchDeleteChatRooms({ ❷ ids: archivedRooms.map( (room) => room.id ) }); }
❶ First we must find all resources that are archived.
❷ Once we have the identifiers, we can delete all of them.
不幸的是,这种设计存在几个问题。首先,也是最明显的,它需要至少两个单独的 API 调用。更糟糕的是,正如我们将在第 21 章中了解到的那样,列出资源不太可能是单个请求,而更有可能涉及一长串重复请求以查找所有匹配的资源。其次,也是最重要的,这两种方法结合在一起会产生非原子结果。换句话说,当我们收集所有归档资源的 ID 时,其中一些资源可能已被取消归档。这意味着当我们删除这些资源时,我们可能会删除不再存档的资源!
Unfortunately, there are several problems with this design. First, and most obviously, it requires at least two separate API calls. And to make things even worse, as we’ll learn in chapter 21, listing resources is unlikely to be a single request and is more likely to involve a long list of repeated requests to find all of the matching resources. Second, and most importantly, these two methods being stitched together lead to a nonatomic result. In other words, by the time we collect all of the IDs of archived resources, it’s possible that some of them might have been unarchived. This means that when we’re deleting these resources, we might be deleting resources that aren’t archived anymore!
由于这些主要问题,重要的是我们有一个替代方法,它提供一个单一的方法,允许我们根据一组标准而不是完全基于标识符列表来删除资源。
Because of these major issues, it’s important we have an alternative that provides a single method allowing us to delete resources based on some set of criteria rather than exclusively based on a list of identifiers.
这个pattern 引入了一种新的自定义方法:purge。purge 方法的目的是接受一个可以执行的简单过滤器,并且删除任何符合该过滤条件的结果。本质上是标准列表方式与批量删除方式的结合。然而,我们可以使用单个 API 调用来实现我们的目标,而不是将一个方法的输出通过管道传输到另一个方法的输入(如清单 19.1 所示)。
This pattern introduces an idea of a new custom method: purge. The purpose of the purge method is to accept a simple filter that can be executed, and any results matching that filter criteria are deleted. In essence, it’s a combination of the standard list method with the batch delete method. However, rather than piping the output of one method into the input of another method (as seen in listing 19.1), we can use a single API call to accomplish our goal.
虽然方法及其目的很简单,但我们也必须考虑一个明显的问题:这种方法很危险。正如我们将在第 25 章中看到的,用户无法避免错误,我们经常担心用户删除了他们后来后悔删除的数据。在这种情况下,我们不是向用户提供删除单个资源的工具(标准删除),甚至是删除大量资源的工具(批量删除),而是向他们提供最大的工具。purge 方法允许用户删除资源,而无需了解他们正在删除的内容的全部范围。在适当的条件下(例如,匹配所有资源的过滤器),我们能够完全清除系统中存储的所有数据!
While the method and its purpose are straightforward, we also have to consider the obvious concern: this method is dangerous. As we’ll see in chapter 25, users are not immune from mistakes, and we are often worried about users deleting data that they later regret having deleted. And in this case, rather than handing users a tool to delete a single resource (standard delete) or even a tool to delete lots of resources (batch delete), we’re now handing them the biggest tool of all. The purge method allows users to delete resources without even being aware of the full extent of what they’re deleting. Under the right conditions (e.g., a filter that matches all resources), we’re able to wipe out all the data stored in the system entirely!
为了避免这种潜在的灾难性结果,我们将提供两个特定的杠杆,用户可以将其作为护栏。force首先,在实际删除任何内容之前,我们需要在请求 ( ) 上设置一个明确的布尔标志。其次,如果未设置此标志(因此不会执行请求),我们将提供一种方法来预览如果实际执行请求会发生什么。这将包括将被删除的项目数 ( purgeCount) 以及恰好与结果列表匹配的某些项目的预览 ( purgeSample)。在某些方面,这有点像拥有一个validateOnly请求中的字段(参见第 27 章),但具有相反的默认值:除非明确表示,否则请求始终仅用于验证要求。
To avoid this potentially catastrophic result, we’ll provide two specific levers that users can rely on as guard rails. First, we’ll require an explicit Boolean flag be set on the request (force) before actually deleting anything. Second, in the case that this flag is not set (and therefore the request won’t be executed), we’ll provide a method to preview what would have happened if the request was actually executed. This will include both a count of the number of items that would be deleted (purgeCount) as well as a preview of some of the items that happen to match the list of results (purgeSample). In some ways, this is a bit like having a validateOnly field on the request (see chapter 27), but with the opposite default: that a request is always for validation only unless explicitly requested.
到看看这个方法是如何工作的,让我们先来看看 purge 方法的典型流程。如图 19.1 所示,该过程从提供要应用的过滤器的请求开始,但留下force标志设置为false(或未设置)。因为这相当于一个验证请求,实际上不会删除任何资源,而是删除purgeSample字段将填充一个将被删除的资源标识符列表。此外,purgeCount场将提供与过滤器匹配的资源数量的估计值,因此将被请求删除。
To see how this method works, let’s start by looking at the typical flow of the purge method. As shown in figure 19.1, the process begins with a request providing the filter to be applied, but leaving the force flag set to false (or left unset). Because this is the equivalent of a validation request, no resources will actually be deleted, and instead the purgeSample field will be populated with a list of identifiers of resources that would have been deleted. Additionally, the purgeCount field would provide an estimate of the number of resources that match the filter and therefore would be deleted by the request.
Figure 19.1 Interaction pattern for the purge method
有了来自验证请求的新信息,我们可以仔细检查purgeSample字段中返回的资源是否确实与过滤器中表达的意图相匹配。然后,如果我们决定删除所有包含的资源,我们可以再次发送相同的请求,这次将force标志设置为true。在响应中,只有purgeCount字段应该填充执行请求删除的资源数量。
With this new information from the validation request, we can double-check that the resources returned in the purgeSample field are indeed ones that match the intent expressed in the filter. And then, if we decide to follow through with deleting all the resources included, we can send the same request again, this time setting the force flag to true. In the response, only the purgeCount field should be populated with the number of resources deleted by executing the request.
现在我们已经了解了整体流程,让我们深入了解每个字段的棘手细节并探索它们如何协同工作,从filter字段开始.
Now that we have an idea of the overall flow, let’s get into the tricky details of each of these fields and explore how they work together, starting with the filter field.
作为正如我们将在第 22 章中看到的那样,该filter字段应该像在标准列表方法中一样工作。purge 方法的全部要点在于它提供与标准列表方法几乎相同的功能以及批删除方法。这意味着由 purge 方法指定和执行的过滤器的行为应该与提供给标准列表方法的相同过滤器的行为相同。
As you might expect, the filter field should work exactly as it does on the standard list method, as we’ll see in chapter 22. The whole point of the purge method is that it provides almost identical functionality to the standard list method combined with the batch delete method. This means that a filter specified and executed by the purge method should behave identically to that same filter having been provided to a standard list method.
一个不寻常且可怕的结果是,就像标准列表方法上的空过滤器或未设置的过滤器返回 API 托管的所有资源一样,清除方法也会出现同样的行为。换句话说,我们处于一个非常棘手的情况下,如果用户忘记指定过滤器(或编码错误导致过滤器被设置为undefined或空字符串),该方法将匹配所有现有资源和, 如果强制删除所有资源。虽然肯定很危险,但这就是为什么将其他故障保险装置内置到此设计中的原因。
One unusual, and scary, consequence of this is that just as an empty or unset filter on the standard list method returns all resources hosted by the API, this same behavior is therefore expected for the purge method. In other words, we’re in a very tricky situation where if a user were to forget to specify a filter (or has a coding error resulting in the filter being set to undefined or an empty string), the method will match all existing resources and, if forced, delete all resources. While certainly dangerous, this is why the other fail-safes are built into this design.
Listing 19.2 Minor typos resulting in disastrous consequences
function deleteMatchingMessages(filter: string): number { ❶ const result = PurgeMessages({ parent: "chatRooms/1", filter: fliter, ❷ force: true, }); return result.purgeCount; }
❶该方法接受一个过滤器字符串,并删除所有匹配过滤器的Message资源。
❶ This method accepts a filter string and deletes all Message resources matching the filter.
❷不幸的是,这里的错字(fliter 而不是 filter)将导致过滤器未定义,因此将始终匹配所有资源。
❷ Unfortunately, a typo here (fliter instead of filter) will result in the filter being undefined and therefore will always match all resources.
虽然阻止此类请求可能更安全,但不幸的是,用户确实需要执行此类操作,而且与标准列表方法的一致性至关重要——否则用户可能会开始认为过滤器有效不同的方法不同。因此,例如,我们不能简单地拒绝缺少过滤器的请求。也就是说,像这样的场景是这种想法背后的主要驱动因素,即默认情况下,清除方法就像我们只要求预览一样。在下一节中,我们将更深入地探讨这一点细节。
While it might be safer to prevent requests like these, the unfortunate reality is that users do have a need to perform this type action, and, further, the consistency with the standard list method is critical—otherwise users may start to think that filters work differently for different methods. As a result, we cannot simply reject requests with a missing filter, for example. That said, scenarios like these are the primary drivers behind the idea that, by default, the purge method acts as though we’re asking for a preview only. In the next section, we’ll explore this in more detail.
作为我们将在第 27 章中了解到,我们可以依靠一个特殊的validateOnly标志来使 API 方法仅验证传入的请求,而不实际执行请求本身。我们有目的地选择此字段的名称来推送默认值,以便该方法正常运行,除非明确要求否则(有关此主题的更多讨论,请参阅第 5.2 节)。
As we’ll learn in chapter 27, we can rely on a special validateOnly flag to make an API method validate the incoming request only and not actually execute the request itself. Purposefully, we chose the name of this field to push for a default value such that the method behaves normally unless explicitly asked to do otherwise (see section 5.2 for more discussion on this topic).
虽然这个默认值对这些情况很好,正如我们刚刚在第 19.3.1 节中了解到的那样,但它对 purge 方法来说非常危险,因为它允许一个小错误导致从 API 中删除潜在的大量数据。如果我们完全依赖请求验证,忘记指定请求仅用于验证将导致删除数据,而不是像我们希望的那样提供某种预览。
While this default is fine for those cases, as we just learned in section 19.3.1, it is exceptionally dangerous for the purge method as it allows a tiny mistake to result in deleting a potentially large amount of data from the API. If we rely exclusively on request validation, forgetting to specify that the request was for validation only will result in deleting data rather than providing some sort of preview as we might hope.
Listing 19.3 An omission with the wrong default leading to disastrous results
PurgeMessages({ parent: "chatRooms/1", filter: "...", // validateOnly: true ❶ });
❶如果我们依赖 validateOnly 标志,完全省略它可能会导致意外删除大量数据!
❶ If we rely on a validateOnly flag, omitting it entirely can result in accidentally deleting lots of data!
这是我们实际上希望默认情况下削弱方法而不是相反的少数情况之一。换句话说,如果一个字段被遗忘(可能是用户没有正确阅读文档),默认行为对用户来说应该是安全的,不会导致灾难性的后果。
This is one of the few scenarios where we actually want to have a method crippled by default rather than the other way around. In other words, if a field is forgotten (perhaps a user didn’t properly read the documentation), the default behavior should be safe for the user and not lead to catastrophic consequences.
为了实现这一点,我们依赖于一个名为 的字段force,它与该字段做完全相同的事情,validateOnly但被命名为导致不同的默认行为。由于这种差异,忘记设置此字段(或将其设置为 false)会导致完全安全的结果:根本不会删除任何数据。除了没有数据被删除之外,如果实际执行了请求,我们实际上还可以预览有用的结果。此预览由两个关键信息组成:匹配资源的数量计数以及这些匹配资源的样本集。在下一节中,我们将首先查看结果计数作品。
To make this happen, we rely on a field called force, which does the exact same thing as the validateOnly field but is named to lead to a different default behavior. Thanks to this difference, forgetting to set this field (or leaving it set to false) leads to a completely safe result: no data is deleted at all. In addition to no data being deleted, we actually get a useful preview of the results had the request been actually executed. This preview is made up of two key pieces of information: a count of the number of matching resources as well as a sample set of those matching resources. In the next section, we’ll start by looking at how this count of results works.
而不管关于清除请求是要执行还是仅用于验证,要记住的一个非常有用的信息是计算有多少资源恰好与提供的过滤器匹配。为此,清除响应应包含purgeCount提供此信息的字段。
Regardless of whether a purge request is to be executed or is for validation only, one quite useful piece of information to have in mind is a count of how many resources happen to match the provided filter. To do this, purge responses should include a purgeCount field that provides this information.
但有一个问题:虽然该值应该是实时请求中实际删除的项目的精确计数 ( force: true),但当请求用于验证时,此值可以选择提供合理的估计而不是精确计数,因为在某些情况下查找和计算所有可能的匹配项可能需要大量计算。由于我们不会经历将它们全部删除的过程,并且希望避免浪费计算能力,因此依赖于完全准确计数的估计并不是什么大问题。也就是说,我们的目标是尽可能切合实际,因此估算值至少要在一定程度上反映现实情况,这一点很重要。
There is a catch though: while the value should be an exact count of items actually deleted in a live request (force: true), when the request is for validation only this value can opt to provide a reasonable estimate rather than an exact count because in some cases it might be computationally intensive to find and count all the possible matches. And since we’re not going through the process of deleting them all and would like to avoid wasting computing power, it’s not really a big deal to rely on an estimate over a perfectly accurate count. That said, the goal is to be as realistic as possible, so it’s important that the estimate be at least somewhat reflective of reality.
依赖该字段的估计值时需要考虑的一件事是低估可能会产生严重的误导,应尽可能避免。要了解原因,请考虑响应指示估计有 100 个资源与给定过滤器匹配的场景。这可能会给用户一种错误的信心,即清除方法不会删除那么多资源。如果实际上匹配资源的数量接近 1,000,当真实结果出现时用户首先会感到震惊(与估计的 100 相比显示 1,000 资源已被删除),但当他们意识到如果估计更能反映现实(例如,750 个匹配资源),他们会回去修改他们的过滤器表达式。
One thing to consider when relying on an estimated value for this field is that underestimates can be devilishly misleading and should be avoided as much as possible. To see why, consider the scenario where a response indicates that an estimated 100 resources match a given filter. This might give a user a false sense of confidence that the purge method won’t remove that many resources. If, in truth, the number of matching resources is closer to 1,000, the user will first be shocked when the true results come in (showing 1,000 resources have been deleted compared to the estimate of 100) but will be exceptionally frustrated when they realize that they would have gone back and revised their filter expression had the estimate been more reflective of reality (say, 750 matching resources).
出于这个原因和其他各种原因,查看给定过滤器的匹配资源数量当然是有用的信息(对于实时请求和验证请求),但对于仅验证请求,我们可以提供更有用的数据还提供:一组匹配的资源样本,因此将被删除。在下一节中,我们将探讨如何最好地做到这个。
For this and a variety of other reasons, seeing the number of matching resources for a given filter is certainly useful information (both for live requests and validation requests), but there’s an even more useful bit of data for validation-only requests that we could provide as well: a sample set of the resources that match and therefore will be deleted. In the next section, we explore how best to do this.
作为我们已经看到,预览与提供的过滤器匹配的资源数量是有用的,但肯定不是完美的。事实上,即使计数是一个确切的数字而不是一个估计值,我们仍然必须接受这是一个简单地计算所有匹配资源的单一指标,而且在许多情况下,这种类型的聚合实际上可能会产生误导。例如,考虑 100 个项目中是否有 50 个匹配过滤器。我们怎么知道要删除正确的50 个项目?也许我们打算删除所有 50 个存档资源,而不是要删除所有 50 个未存档资源!匹配项目的计数通常有助于注意到明显的问题,但当问题更微妙时就会失败。
As we’ve seen, a preview of the number of resources matching a provided filter is useful but certainly not perfect. In fact, even when the count is an exact number rather than an estimate, we still have to accept that this is a single metric that simply counts all matching resources, and in many cases this type of aggregate can actually be misleading. For example, consider if 50 of 100 items match a filter. How do we know we’re about to delete the right 50 items? Perhaps we meant to delete all 50 archived resources and instead are about to delete all 50 unarchived resources instead! Counts of matching items are often helpful for noticing glaring problems but fail when the issues are more subtle.
为了解决这个问题,除了匹配资源的计数之外,我们还可以依靠验证响应提供一个项目的样本子集,这些项目将在名为purgeSample. 该字段应包含匹配资源的标识符列表,然后可以对其进行抽查以确保准确性。例如,我们可以检查一些返回的资源并验证它们确实标记为已存档,而不是相反。
To address this problem, in addition to the count of matching resources, we can rely on a validation response providing a sample subset of items that would be deleted in a field called purgeSample. This field should contain a list of identifiers of matching resources, which can then be spot-checked for accuracy. For example, we could check a few of the resources returned and verify that they are indeed marked as archived and not the other way around.
尽管这需要一些额外的工作,但它仍然非常有用。例如,在用户界面中,我们可能会使用批量获取方法检索其中的一些项目,并将它们显示给用户进行验证,以确保预览中列出的资源看起来像他们打算删除的资源。如果用户认为这些资源看起来都不合适,他们可以通过重新发送请求并force设置为来继续执行请求true。
Despite the fact that this requires some extra work, it is still quite useful. For example, in a user interface we might retrieve a few of these items using the batch get method and display them to a user for verification to be sure that the resources listed in the preview look like ones they intend to delete. If the user decides that none of these resources look out of place, they can proceed with executing the request by resending it with force set to true.
但这会引出一个明显的问题:这个预览示例中应该包含多少项?一般来说,由于目标是帮助捕获过滤器表达式中的任何错误,因此样本大小足够大以便用户注意到是否有什么地方看起来不合适(例如,如果不应该匹配的资源恰好与出现在样本集中)。因此,一个好的准则是为较大的数据集提供至少 100 个项目,同时为相对便宜的较小数据集提供精确匹配。询问。
But this leads to an obvious question: how many items should be in this preview sample? In general, since the goal is to help catch any mistakes in the filter expression, it’s important that the sample size be large enough for a user to notice if something looks out of place (e.g., if a resource that shouldn’t match happens to appear in the sample set). As a result, a good guideline is to provide at least 100 items for larger data sets, while providing exact matches for smaller data sets that are relatively inexpensive to query.
这最后一个要担心的问题有点棘手,这里至少值得一提:一致性。如果我们发送一个仅验证的清除请求以删除少量资源,但是当我们稍后发送实际执行请求时,数据已经发生变化,使得更多资源与过滤器匹配,会发生什么情况?换句话说,有没有办法保证验证期间返回的数据与稍后在执行期间删除的数据相匹配?不幸的是,简短的回答是否定的。
The final issue to worry about is quite a bit trickier and one that deserves at least a mention here: consistency. What happens if we send a validation-only purge request for few resources to be deleted, but by the time we send the actual request for execution later, the data has changed such that many more resources match the filter? In other words, is there a way to have any guarantee that the data returned during validation will match the data that will be deleted later during execution? Unfortunately, the short answer is no.
即使我们有能力在特定时间点对数据快照执行查询,对过去出现的数据执行清除请求也不太可能产生预期的结果。事实上,如果此行为确实是意图,那么标准列表请求和批量删除请求的组合已经支持它。
Even if we have the ability to perform queries over snapshots of data at a specific point in time, executing the purge request over data as it appeared in the past is unlikely to lead to the desired result. As a matter of fact, if this behavior is really the intent, then it’s already supported by the combination of a standard list request and a batch delete request.
此外,虽然在验证请求和实时请求之间数据发生变化的情况下,我们可能要求清除方法在技术上失败,但该方法在任何足够大的、并发的、易变的数据上实际上将变得无用放。在 API 的世界里,这并不少见发生。
Additionally, while it is technically possible that we could require the purge method to fail in the case where data has changed between the time of a validation request and a live request, the method would become practically useless on any sufficiently large, concurrent, volatile data set. And in the world of APIs, this is not an uncommon occurrence.
现在我们已经完全掌握了 purge 方法的工作原理,让我们看一个删除所有Message资源的方法的简短示例提供了一组要在筛选细绳。
Now that we have a full grasp on how the purge method works, let’s look at a short example of a method to remove all Message resources provided a set of criteria to be specified in a filter string.
Listing 19.4 Final API definition
abstract class ChatRoomApi { @post("/{parent=chatRooms/*}/messages:purge") PurgeMessages(req: PurgeMessagesRequest): PurgeMessagesResponse; } interface PurgeMessagesRequest { parent: string; filter: string; force?: boolean; } interface PurgeMessagesResponse { purgeCount: number; purgeSample: string[]; }
在如果您没有注意到,这是一种危险的方法。这有点像将火箭筒交给 API 的用户,让他们能够非常轻松、非常快速地销毁大量数据。虽然设计的方法试图提供尽可能多的安全检查,但它仍然为用户错误地销毁大量(如果不是全部)数据的可能性打开了大门。因此,除非这是绝对必要的,否则通常最好避免在应用程序接口。
In case you haven’t noticed, this is a dangerous method. It’s a bit like handing a bazooka to users of an API, giving them the ability to destroy a lot of data very easily and very quickly. While the method as designed attempts to provide as many safety checks as possible, it still opens the door to the possibility that users will mistakenly destroy a large amount, if not all, of their data. As a result, unless this is an absolute necessity, it’s generally a good idea to avoid supporting this functionality in an API.
Why should the custom purge method be limited to only those cases where it’s absolutely necessary?
Why does the purge method default to executing validation only?
If a resource supports soft deletion, what is the expected behavior of the purge method on that resource? Should it soft-delete the resources? Or expunge them?
What is the purpose of returning the count of affected resources? What about the sample set of matching resources?
The custom purge method should be used to delete multiple resources matching a specific set of filter criteria but should be supported only if absolutely necessary.
By default, purge requests should be exclusively for validation rather than actually deleting any resources.
All purge responses should include a count of the number of resources affected (and a sample set of matching results), though this may be estimated for a validation request.
The purge method should adhere to the same consistency guidelines as the standard list method.
到目前为止,将任何新数据写入 API 都涉及到创建资源,以及它们的唯一标识符和模式。不幸的是,尽管我们可能希望这是创建数据的唯一方式,但它有时并不完全符合世界的现实。事实上,有些场景需要写入数据但事后不能唯一标识,例如日志文件中的条目或要聚合到统计数据中的时间序列数据点。在此模式中,我们解决了编写数据而不是创建资源的想法,以及这个新概念如何仍然符合我们现有的面向资源的设计原则。
So far, writing any new data into an API has involved creating resources, complete with their unique identifiers and schemas. Unfortunately, as much as we might want this to be the one and only way to create data, it sometimes does not quite fit with the realities of the world. Indeed, there are scenarios where data needs to be written but not uniquely identified after the fact, such as entries in a log file or time series data points to be aggregated into statistics. In this pattern, we address this idea of writing data rather than creating resources and how this new concept can still fit with our existing resource-oriented design principles.
到目前为止,在我们将数据写入 API(添加新数据或更新现有数据)的所有讨论中,我们都从资源的角度进行思考和工作。即使在我们可能会改变关于什么构成资源的规则的情况下,例如在第 12 章中,支撑始终是资源的概念。这些资源一直充当着单一的、可寻址的、唯一可识别的数据块,我们可以将它们带入存在、操作、读取,然后在我们用完它们后将它们带回不存在,它们为我们提供了很好的服务,所以远的。然而不幸的是,它们不一定足以涵盖现实世界中可能出现的所有方面和场景,因此需要在 API 设计领域加以考虑。
In all the discussion so far where we’ve written data to an API (either adding new data or updating existing data), we’ve thought and worked in terms of resources. Even in cases where we might bend the rules about what constitutes a resource, such as in chapter 12, the underpinning has always been the concept of a resource. These resources have always acted as single, addressable, uniquely identifiable chunks of data that we can bring into existence, operate on, read, and then bring back out of existence when we’re done with them, and they’ve served us well so far. Unfortunately, however, they aren’t necessarily sufficient to cover all aspects and scenarios that might appear in the real world and therefore need to be considered in the world of API design.
例如,考虑收集时间序列统计数据等情况。像这样存储统计信息的一种明显方法是拥有一个DataPoint资源并依靠标准的创建方法来记录传入的数据点。但这有点像单独包装和标记每粒米,而不是购买 5 磅重的袋子。一般来说,对于这样的系统,用户对聚合比对单个数据点更感兴趣。换句话说,我们更有可能询问某个月数据点的平均值,而不是请求使用唯一标识符查看特定的单个数据点。这很常见,以至于许多分析数据处理系统、时间序列数据库或数据仓库(例如,Google 的 BigQuery [ https://cloud.google .com/bigquery ] 或 InfluxData 的 InfluxDB [ https://www.influxdata.com/ products/ influxdb/ ]) 甚至不支持存储数据点的唯一标识符!
Consider, for example, cases such as collecting time series statistics. One obvious way to store statistics like this is to have a DataPoint resource and rely on the standard create method to record an incoming data point. But this is a bit like individually wrapping and labeling each grain of rice rather than buying a 5-pound bag. In general, with systems like this users are more interested in aggregates than individual data points. In other words, it’s far more likely that we’ll ask for the average value of a data point for the month rather than requesting to view a specific single data point using a unique identifier. This is so common that many analytical data processing systems, time series databases, or data warehouses (e.g., Google’s BigQuery [https://cloud.google .com/bigquery] or InfluxData’s InfluxDB [https://www.influxdata.com/products/ influxdb/]) don’t even support unique identifiers for data points being stored!
这就引出了一个明显的问题:如果我们使用其中一个系统来存储我们的分析数据,那么我们首先应该如何将数据导入 API?我们是否应该假装有一个唯一标识符(在数据库中存储一个特殊字段)并且像往常一样只依赖标准的创建方法?我们是否应该修改标准的创建方法,使其将数据插入数据库,但不返回可寻址的资源,也许将id字段留空?或者我们应该尝试完全不同的东西吗?此模式探索了一种替代方案,旨在标准化如何在我们通常面向资源的 API 中最好地处理此类非面向资源的数据。
This leads to the obvious question: if we’re using one of these systems for storing our analytical data, how exactly are we supposed to get data into the API in the first place? Should we pretend to have a unique identifier (storing a special field in the database) and just rely on the standard create method as usual? Should we modify the standard create method so that it inserts data into the database but doesn’t return an addressable resource as a result, perhaps leaving the id field blank? Or should we try something else entirely? This pattern explores an alternative that aims to standardize how best to handle this type of non-resource-oriented data in our generally resource-oriented APIs.
这个pattern 通过提供一个称为 write 的特殊自定义方法来工作。与标准创建方法类似,自定义写入方法的工作是将新数据插入 API;但是,这样做的方式是生成的数据条目是匿名的(即数据没有唯一标识符)。通常,这是因为生成的数据不可寻址,并且事后无法检索、更新或删除。
This pattern works by presenting a special custom method called write. Similar to a standard create method, a custom write method’s job is to insert new data into the API; however, it does this in such a way that the resulting data entry is anonymous (i.e., the data has no unique identifier). Generally, this is because the resulting data isn’t addressable and cannot be retrieved, updated, or deleted after the fact.
相反,不应检索单个记录,而应仅在汇总的基础上探索此类数据。这意味着他们可以请求基于这些数据点的聚合值,例如条目总数或这些条目的平均值,而不是像他们使用典型资源那样允许用户请求单个数据点。您可能会猜到,这种交互方式对于显示大量系统统计信息的仪表板式 API 很重要。
Instead, rather than retrieving individual records, this type of data should only be explored on an aggregate basis. This means that rather than allowing users to request a single data point as they would with a typical resource, they can request an aggregate value based on these data points, such as the total number of entries or the average value of these entries. As you might guess, this manner of interaction is important for dashboard-style APIs that present lots of statistics about a system.
由于这些数据点与我们已经习惯的资源具有如此不同的访问模式,因此使用不同的术语来描述正在写入的数据几乎肯定是个好主意。在这种情况下,我们将使用术语条目来表示通过这种新的写入方法插入到 API 中的数据点,而不是指资源。
Since these data points have such a different access pattern from the resources we’ve grown accustomed to, it’s almost certainly a good idea to use different terminology to describe the data being written. In this case, rather than referring to resources, we’ll use the term entry for the data points that are being inserted into the API via this new write method.
为了将所有这些放在一起,图 20.1 中的序列图显示了写入条目和读取聚合数据(例如,计数)的示例。
To put this all together, an example of writing entries and reading aggregate data (e.g., a count) is shown as a sequence diagram in figure 20.1.
Figure 20.1 Sequence of events for the write method
在下一节中,我们将探索 write 方法的更细微的细节以及我们如何着手实现它。
In the next section, we’ll explore the more nuanced details of the write method and how we can go about implementing it.
尽管write 方法在本质上与标准的 create 方法非常相似,当然也有一些值得探索的差异。首先,让我们看一下方法的返回类型。虽然标准的 create 方法返回一个新创建的资源,但 write 方法并没有返回一个资源。事实上,write 方法涉及将数据添加到集合(作为另一个匿名成员加入组)或以流方式使用以增加计数器或更新聚合(例如移动平均数)。这种行为是该方法的特殊之处,但它也意味着除了成功的结果或出现问题时的错误之外,我们无法返回任何有用的东西。因此,write 方法应该什么都不返回,或者void.
While the write method is quite similar in nature to the standard create method, there are certainly a few differences worth exploring. First, let’s look at the return type of the method. While the standard create method returns a newly created resource, the write method doesn’t exactly have a resource to return. In fact, the write method involves data being added to a collection (either joining the group as another anonymous member) or being used in a streaming manner in order to increment a counter or update an aggregate (such as a moving average). This behavior is what makes the method special, but it also means that there’s nothing useful that we could return other than a successful result or an error in case something went wrong. Due to this, the write method should return nothing whatsoever, or void.
下一个要回答的问题是我们究竟如何传输数据的有效载荷。resource标准创建方法依赖于接受要创建的资源值的单个字段 ( )。同样,与标准的 create 方法不同,write 方法不处理资源,而是使用条目的概念。幸运的是,这使我们得出一个明显而简单的结论:将写入请求视为创建请求,但将字段从重命名resource为entry.
The next question to answer is how exactly we transport the payload of data. The standard create method relies on a single field (resource) that accepts the resource value to be created. Again, unlike the standard create method, the write method doesn’t deal in resources, and instead uses the concept of entries. Luckily, this leads us to an obvious and simple conclusion: treat a write request just like a create request, but rename the field from resource to entry.
Listing 20.1 Example write request
interface WriteChatRoomStatEntryRequest { parent: string; entry: ChatRoomStatEntry; ❶ }
❶ Here we use an entry field rather than a resource field.
最后,我们不得不想知道 write 方法的正确 HTTP URL 绑定是什么。例如,假设我们正在尝试编写有关ChatRoom资源的一些统计信息. 正如我们在第 9 章,特别是第 9.3.2 节中了解到的,通常最好以集合而不是父资源为目标,这意味着 write 方法应该避免使用类似 的 URL /chatRooms/1:writeStatEntry,而应该使用类似 的 URL /chatRooms/1/statEntries:write。虽然这可能是一个有争议的选择(因为statEntries它不是真正的资源集合),但事实是我们将来可以通过其他几种方式与条目集合进行交互。例如,我们可以使用批写入方法(第 18 章)一次添加多个条目,或者使用清除方法(第 19 章)删除所有条目。
Finally, we have to wonder about what the right HTTP URL binding is for the write method. For example, imagine that we’re trying to write some statistics about a ChatRoom resource. As we learned in chapter 9, specifically section 9.3.2, it’s generally best to target the collection rather than the parent resource, which means that the write method should avoid a URL looking like /chatRooms/1:writeStatEntry, and should instead favor a URL looking something like /chatRooms/1/statEntries:write. While this might be a contentious choice (since statEntries isn’t really an actual collection of resources), the fact remains that we will be able to interact with the collection of entries in the future through several other means. For example, we might be able to use a batch write method (chapter 18) to add multiple entries at once or use the purge method (chapter 19) to remove all entries.
现在我们已经很好地掌握了基础知识,还有一个更复杂的主题要涵盖:一致性。
Now that we have a good grasp on the basics, there’s a more complex topic to cover: consistency.
在通常,当我们向 API 插入数据时(例如通过标准的创建方法),我们希望能够立即看到该操作的结果。换句话说,如果 API 说我们创建一些数据的请求成功了,我们应该能够立即从 API 中读回该数据。事实上,这非常重要,以至于当我们遇到操作可能需要一段时间才能完成的情况时,我们依靠诸如长时间运行的操作(第 10 章)之类的东西来帮助更深入地了解数据何时可用系统。
In general, when we insert data to an API (such as through the standard create method), we want to be able to see the results of that operation immediately. In other words, if the API says that our request to create some data succeeded, we should be able to read that data back from the API right away. In fact, this is so important that when we run into situations where an operation might take a while to complete, we rely on things like long-running operations (chapter 10) to help provide more insight into when the data will be available in the system.
由于 write 方法的行为有点像标准的 create 方法,我们有一些重要的问题需要回答:它是否应该以相同的方式保持一致,操作完成后数据是否立即可读?如果不是,我们是否应该依靠长时间运行的操作来跟踪进度?如果没有,我们应该怎么办?
Since the write method behaves a bit like a standard create method, we have some important questions to answer: Should it be consistent in the same way, with data being immediately readable after the operation completes? If not, should we rely on long-running operations to track the progress? If not, what should we do?
要回答这些问题,我们首先必须提醒自己,write 方法的目标是允许将不可寻址的数据添加到系统中。这意味着我们读取数据的方法与我们读取通过标准创建方法添加的数据的方法非常不同。事实上,由于我们几乎总是通过聚合读取这种类型的数据,因此很难(如果不是不可能)知道我们实际上是在读取我们自己添加的数据,而不是其他人调用 write 方法添加的数据独立地。简而言之,这意味着让我们自己遵守一个标准,即在方法完成时所有数据都必须是可读的,这实际上没有任何意义。换句话说,write 方法立即返回是完全没问题的(如图 20.2 所示),
To answer these, we first must remind ourselves that the goal of the write method is to allow data that is not addressable to be added to a system. This means that the method by which we read the data is very different from the way we would read data added by the standard create method. In fact, since we almost always read this type of data through aggregates, it will be difficult—if not impossible—to know whether we’re actually reading data that we ourselves added and not data that was added by someone else invoking the write method independently. In short, this means that it doesn’t really make any sense to hold ourselves to a standard whereby all data must be readable by the time the method completes. In other words, it’s perfectly fine for the write method to return immediately (as shown in figure 20.2), even before the data becomes visible to anyone using the API.
图 20.2 当数据进入分析管道时,写入数据会立即得到响应。
Figure 20.2 Writing data gets an immediate response while data goes into an analytics pipeline.
这恰好适合涉及大规模分析系统的大多数用例,因为这些用例往往依赖于数据处理管道中的最终一致性和潜在的长期延迟。如果我们必须等待所有计算完成才能返回结果,我们可能会遇到一些非常严重的请求延迟问题,从而使该方法的用处大大降低。
This happens to fit nicely with the majority of use cases involving large-scale analytics systems, as these tend to rely on eventual consistency and potentially long delays in data-processing pipelines. If we had to wait for all of the computation to complete before returning a result, we might run into some pretty serious request latency problems, rendering the method much less useful.
不过,这导致了一个有趣的选择:如果我们想在数据可见之前做出响应,但我们不能轻易地同步进行,那么使用 LRO(参见第 10 章)立即返回并允许用户等待怎么样?结果变得可见?不幸的是,这个想法有两个问题。首先,通常不可能知道单个数据何时作为聚合统计信息的一部分持久存在。这主要是因为单个条目不应包含唯一标识符,因此无法通过分析管道一直跟踪单个数据点。
This, though, leads to an interesting option: if we want to respond before the data is visible, but we can’t easily do so synchronously, what about using LROs (see chapter 10) to return right away and allow users to wait on the results to become visible? Unfortunately, there are two problems with this idea. First, it’s often impossible to know when an individual piece of data becomes persisted as part of the aggregated statistical information. This is primarily due to the fact that a single entry should contain no unique identifier, so there’s no way to track a single data point all the way through an analytics pipeline.
其次,即使我们可以通过系统跟踪每个数据点,依赖写入方法而不是标准创建方法的要点之一是我们不想单独存储所有条目。相反,我们希望通过聚合数据来节省空间和计算时间。如果我们决定每次调用 write 方法都应该创建一个新Operation资源,我们基本上已经将问题从一个地方转移到另一个地方,而没有真正节省任何空间或能源。
Second, even if we could track each data point through the system, one of the main points for relying on a write method rather than the standard create method was that we didn’t want to store all of the entries individually. Instead we wanted to save space and compute time by aggregating data. If we then decide that each invocation of the write method should create a new Operation resource, we’ve basically shifted the problem from one place to another without really saving any space or energy.
基于此,作为写入方法的结果返回 LRO 几乎肯定不是一个好主意。如果向用户传达他们的数据已被接受到管道中很重要,但他们不应该期望它在相当长的一段时间内可见,那么一个好的替代方法是简单地返回一个 HTTP202 Accepted响应代码而不是典型的 HTTP200 OK响应代码。此外,如果担心向 write 方法提供重复条目,请求重复数据删除模式(第 26 章)应该非常适合帮助避免此问题。
Based on this, it’s almost certainly a bad idea to return LROs as a result of the write method. If it’s important to communicate to users that their data has been accepted into a pipeline but they should not expect it to be visible for quite some time, a good alternative is to simply return an HTTP 202 Accepted response code rather than the typical HTTP 200 OK response code. Further, if there’s any concerns about duplicate entries being provided to the write method, the request deduplication pattern (chapter 26) should be a perfect fit to help avoid this problem.
有了这个,让我们看看我们如何应用这个模式并定义一个支持写入的 API方法。
With that, let’s look at how we might apply this pattern and define an API supporting the write method.
让我们想象一下,我们想要开始一些关于聊天室的任意统计。这可能包括诸如用户打开特定聊天室的频率之类的事情,但我们希望为将来添加的数据点保留一些灵活性。为此,我们可能会设计一个ChatRoomStatEntry界面这是一个由字符串和标量值组成的简单键值对,我们可以稍后对其进行聚合和分析。
Let’s imagine that we want to start some arbitrary statistics about chat rooms. This might include things like how frequently users open a specific chat room, but we want to keep some flexibility for the future about the data points we add. To do this, we might craft a ChatRoomStatEntry interface that is a simple key-value pair of a string and a scalar value that we can then aggregate and analyze later on.
此外,我们可能希望支持编写多个ChatRoomStatEntry资源立刻。为此,我们可以依靠批量写入方法(有关更多信息,请参阅第 18 章批方法)。
Further, we might want to support writing multiple ChatRoomStatEntry resources at once. To do that, we can rely on a batch write method (see chapter 18 for more on batch methods).
Listing 20.2 Final API definition
abstract class ChatRoomApi { @post("/{parent=chatRooms/*}/statEntries:write") ❶ WriteChatRoomStatEntry(req: ➥ WriteChatRoomStatEntryRequest): void; ❷ @post("/{parent=chatRooms/*}/statEntries:batchWrite") ❶ BatchWriteChatRoomStatEntry(req: ➥ BatchWriteChatRoomStatEntryRequest): void; ❷❸ } interface ChatRoomStatEntry { name: string; value: number | string | boolean | null; } interface WriteChatRoomStatEntryRequest { parent: string; entry: ChatRoomStatEntry; } interface BatchWriteChatRoomStatEntryRequest { parent: string; requests: WriteChatRoomStatEntryRequest[]; }
❶正如我们在 20.3.1 节中了解到的,这些自定义方法针对的是集合,而不是父资源。
❶ As we learned in section 20.3.1, these custom methods target the collection, not the parent resource.
❷ Rather than returning a resource, here we return nothing.
❸ Similarly, even batch methods return nothing as the result.
不像到目前为止,我们已经看过许多设计模式,但这个模式与众不同之处在于用例非常独特。事实上,这个用例几乎专门用于涉及分析数据的案例,而我们考虑的几乎所有数据本质上都是事务性的。因此,并没有那么多替代方法可以将此类分析数据安全地添加到 API。
Unlike many of the design patterns we’ve looked at so far, this one stands apart in that the use case is quite unique. In fact, this use case is almost exclusively for cases involving analytical data, whereas almost all of the data we’ve considered has been transactional in nature. As a result, there are not all that many alternatives for safely adding this type of analytical data to an API.
正如我们所了解的,当然可以依赖标准的创建方法并将单个数据点视为完整资源,每个数据点都有自己唯一的标识符;然而,这种类型的设计不太可能适用于大多数分析存储和数据处理系统。此外,存储的数据几乎肯定会增长得相当快并且变得难以管理。因此,在 API 需要支持分析数据摄取以及更传统的面向资源的情况下,这可能是最佳选择设计。
As we learned, it’s certainly possible to rely on the standard create method and treat individual data points as full resources, each with their own unique identifier; however, this type of design is unlikely to work all that well with the majority of analytical storage and data-processing systems. Further, the data stored will almost certainly grow quite quickly and become difficult to manage. As a result, this is likely to be the best choice in the case where an API needs to support analytical data ingestion alongside more traditional resource-oriented design.
If we’re worried about ingesting duplicate data via a write method, what’s the best strategy to avoid this?
Why should a write method return no response body? Why is it a bad idea for a write method to return an LRO resource?
If we want to communicate that data was received but hasn’t yet been processed, what options are available? Which is likely to be the best choice for most APIs?
When data needs to be ingested into a system (e.g., analytical data entries), we should rely on a custom write method rather than creating resources that will never be addressed individually.
Data loaded into an API via a write method is a one-way street and cannot be later removed from the API.
Write 方法(及其批处理版本)除了返回结果状态代码外,不应返回任何响应。他们根本不应该返回资源——即使是 LRO 资源——除非在特殊情况下。
Write methods (and their batch versions) should return no response other than the resulting status codes. They should not return a resource at all—even an LRO resource—except in special circumstances.
Rather than resources, the write method deals with entries, which are similar to resources but are not addressable and, in many cases, ephemeral.
在此模式中,我们将探索当资源数量或单个资源的大小对于单个 API 响应来说太大时如何使用数据。我们不会在一个响应界面中期待所有数据,而是进入一个来回序列,我们一次请求一小块数据,迭代直到没有更多数据可供使用。此功能在许多 Web 界面中很常见,我们可以在其中转到下一页结果,对于 API 也同样重要。
In this pattern, we’ll explore how to consume data when the number of resources or the size of a single resource is simply too large for a single API response. Rather than expecting all of the data in a single response interface, we’ll enter a back-and-forth sequence whereby we request a small chunk of data at a time, iterating until there is no more data to consume. This functionality is commonly seen in many web interfaces, where we go to the next page of results, and is equally important for APIs.
在典型的 API 中,消费者可能需要检索和浏览他们的资源。随着这些资源在规模和数量上的增长,期望消费者以单个大块读取他们的数据变得越来越不合理。例如,如果一个 API 使用 100 GB 的存储空间提供对 10 亿个数据条目的访问,那么期望消费者使用单个请求和响应来检索这些数据可能会非常缓慢,甚至在技术上是不可行的。我们怎样才能最好地公开一个允许消费者与这些数据交互的接口?换句话说,我们如何才能避免强迫消费者贪多嚼不烂?显而易见的答案是将数据拆分为可管理的分区,并允许消费者与这些数据子集进行交互,但这又引出了另一个问题:我们如何以及在何处拆分数据?
In a typical API, consumers may need to retrieve and browse through their resources. As these resources grow in both size and number, expecting consumers to read their data in a single large chunk becomes more and more unreasonable. For example, if an API offered access to 1 billion data entries using 100 GB of storage space, expecting a consumer to retrieve this data using a single request and response could be painfully slow or even technically infeasible. How best can we expose an interface that allows consumers to interact with this data? In other words, how can we avoid forcing consumers to bite off more than they can chew? The obvious answer is to split the data into manageable partitions and allow consumers to interact with these subsets of the data, but this leads to yet another question: how and where do we split the data?
我们可能会选择在任意两条记录之间画一条分界线,但这并不总是足够的,特别是在单个记录本身可能变得难以处理的情况下(例如,单个 100 MB 的对象)。在这种情况下,将数据分成合理的块的问题变得更加模糊,特别是如果该块是结构化数据(例如,数据库中的 10 MB 行)。我们应该在哪里拆分单个记录中的数据?我们应该为消费者提供对这些分割点的多少控制权?
We might choose to draw a dividing line between any two records, but this won’t always be sufficient, particularly in cases where single records might grow to be unwieldy in size themselves (e.g., a single 100 MB object). In this case the question of splitting the data into reasonable chunks becomes even more fuzzy, particularly if that chunk is structured data (e.g., a 10 MB row in a database). Where should we split the data inside a single record? How much control should we provide to consumers over these split points?
另一方面,在某些情况下,消费者实际上希望使用单个请求和响应(例如,备份或导出所有数据)交付数据库中的所有记录;但是,这两种情况不一定相互排斥,因此这不会改变我们仍然需要一种与合理大小的块中的数据进行交互的方式的事实。对于这种常见情况,有一种模式在许多网站上已经变得非常普遍,并且可以很好地应用到 API:分页。
On the other hand, there are some scenarios where consumers actually want all the records in the database delivered using a single request and response (e.g., backing up or exporting all data); however, these two scenarios are not necessarily mutually exclusive, so this doesn’t change the fact that we still need a way of interacting with data in reasonably sized chunks. For this common scenario, there’s a pattern that has become quite common on many websites and carries over nicely to APIs: pagination.
这术语分页来自于我们想要翻阅数据、分块消费数据记录的想法,就像翻阅书中的页面一样。这允许消费者一次请求一个块,API 将响应相应的块,以及一个指示消费者如何检索下一个块的指针。图 21.1 是一个流程图,展示了消费者如何从一个 API 请求多个页面的资源,直到没有更多资源为止。
The term pagination comes from the idea that we want to page through the data, consuming data records in chunks, just like flipping through pages in a book. This allows a consumer to ask for one chunk at a time and the API responds with the corresponding chunk, along with a pointer for how the consumer can retrieve the next chunk. Figure 21.1 is a flow diagram demonstrating how a consumer might request multiple pages of resources from an API until there are no more left.
Figure 21.1 Flow of pagination
为实现这一点,我们的分页模式将依赖于游标的概念,使用不透明的页面标记作为表示页码的松散等价物的一种方式。考虑到这种不透明性,API 响应结果块和下一个标记将很重要。当谈到处理大型单一资源时,相同的模式可以应用于每个资源,其中一个资源的内容可以构建在多个页面上数据。
To accomplish this, our pagination pattern will rely on the idea of a cursor, using opaque page tokens as a way of expressing the loose equivalent of page numbers. Given this opacity, it will be important that the API responds both with the chunk of results as well as the next token. When it comes to handling large single resources, the same pattern can apply to each resource, where the content of a resource can be built up over multiple pages of data.
在在高层次上,我们真的想要一种从我们离开的地方开始的方法,有效地查看可用数据的特定窗口。为此,我们将依靠三个不同的字段来传达我们的意图:
At a high level, we really want a way of picking up where we left off, effectively viewing a specific window of the available data. To do this, we’ll rely on three different fields to convey our intent:
pageToken, which represents an opaque identifier, meaningful only to the API server of how to continue a previously started iteration of results
maxPageSize, which allows the consumer to express a desire for no more than a certain number of results in a given response
nextPageToken, which the API server uses to convey how the consumer should ask for more results with an additional request
为了更清楚地看到这一点,我们可以用这些字段替换图 21.2 中的一些文本。
To see this more clearly, we can replace some of the text in figure 21.2 with these fields.
Figure 21.2 Pagination using these specific fields
如果我们要将这种模式转化为 API 定义,我们最终会得到一些看起来与标准列表非常相似的东西,pageToken并maxPageSize添加到请求和nextPageToken响应中。
If we were to translate this pattern into an API definition, we would end up with something that looks very similar to a standard list, with pageToken and maxPageSize added to the request and nextPageToken added to the response.
Listing 21.1 API specification for paginating in a standard list method
abstract class ChatRoomApi { @get("/chatRooms") ListChatRooms(req: ListChatRoomsRequest): ListChatRoomsResponse; } interface ListChatRoomsRequest { pageToken: string; ❶ maxPageSize: number; ❶ } interface ListChatRoomsResponse { results: ChatRoom[]; nextPageToken: string; ❶ }
❶标准列表方法requests和response新增三个字段,支持分页
❶ Adding three new fields to the standard list method requests and responses to support pagination
这个定义本身并没有那么有趣,但事实证明,这些领域中的每一个都有一些有趣的隐藏秘密。让我们首先探索无害视maxPageSize域。
This definition itself isn’t all that interesting, but it turns out that each of these fields has some interesting hidden secrets. Let’s first explore the harmless looking maxPageSize field.
在乍一看,设置所需页面的大小(即,应返回的结果数)似乎无伤大雅,但事实证明,它的作用远不止于此。让我们从查看字段本身的名称开始:maxPageSize。
At first glance, setting the size of the page you want (i.e., the number of results that should be returned) seems pretty innocuous, but it turns out that there’s quite a bit more to it. Let’s start by looking at the name of the field itself: maxPageSize.
第一的,为什么我们使用最大值而不是仅仅设置确切的大小?换句话说,为什么我们最多返回 10 个结果而不是恰好 10 个结果?事实证明,在大多数情况下,API 服务器可能总是能够返回准确数量的结果;然而,在许多大型系统中,如果不支付大量成本溢价,这根本不可能实现。
First, why do we use a maximum instead of just setting the exact size? In other words, why do we return up to 10 results instead of exactly 10 results? It turns out that in most cases an API server might always be able to return an exact number of results; however, in many larger-scale systems this simply won’t be possible without paying a significant cost premium.
例如,假设一个系统在一个表中存储了 100 亿行数据,由于大小限制,该表没有二级索引(这将使查询此数据更高效)。我们还假设正好有 11 行匹配行,其中 5 行在开头,接着是 50 亿行,接着是剩余的 6 行,再后面是其余数据,如图 21.3 所示。最后,我们还假设我们的目标是在 200 毫秒内返回大多数响应(例如,99%)。
For example, imagine a system that stores 10 billion rows of data in a table, which, due to size constraints, has no secondary indexes (which would make querying this data more efficient). Let’s also imagine that there are exactly 11 matching rows with 5 right at the start, followed by 5 billion rows, followed by the remaining 6, further followed by the rest of the data, as shown in figure 21.3. Finally, let’s also assume that we have a goal of returning most responses (e.g., 99%) within 200 milliseconds.
Figure 21.3 Matching rows could be widely distributed.
在这种情况下,当用户请求包含 10 个结果的页面时,我们有两个选项可供选择。一方面,我们可以恰好返回 10 个结果(因为它们足以填满页面),但由于它必须扫描大约 50 亿行才能完全填满页面,因此该查询不太可能始终如一地快速。另一方面,我们还可以返回搜索到特定截止时间(此处可能为 180 毫秒)后找到的 5 个匹配行,然后在后续请求更多数据时从中断处继续。
In this scenario, when a user asks for a page of 10 results, we have two options to choose between. On the one hand, we could return exactly 10 results (as there are enough to fill up the page), but it’s unlikely that this query will be consistently fast given that it must scan about 5 billion rows in order to fill the page completely. On the other hand, we could also return the 5 matching rows that we find after having searched until our specific cut-off time (here this might be 180 ms) and then pick up where we left off on a subsequent request for more data.
考虑到这些限制,总是试图填充整个页面将很难区分违反我们的延迟目标和请求复杂性的简单变化之间的区别。换句话说,如果请求很慢(超过 200 毫秒),这可能是因为某些东西确实损坏了,或者因为请求只是搜索了大量数据——如果不完全了解,我们将无法区分这两者一点工作。如果我们在我们的时间限制内尽可能多地填满一个页面,我们通常可以返回恰好 10 个结果(可能是因为我们将它们放在一起),但我们不会在我们的数据丢失的情况下把自己逼到角落里集变得非常大。因此,我们的接口契约最有意义的是指定我们将返回maxPageSize结果而不是完全那样数字。
Given these constraints, always trying to fill the entire page will make it very difficult to tell the difference between violations of our latency goal and simple variations in request complexity. In other words, if the request is slow (takes more than 200 ms), this could be because something is actually broken or because the request just searches a lot of data—and we won’t be able to distinguish between the two without quite a bit of work. If we fill a page as much as possible within our time limit, we may often be able to return exactly 10 results (perhaps because we put them close together), but we don’t paint ourselves into a corner in the case where our data set becomes exceedingly large. As a result, it makes the most sense for our interface contract to specify that we will return up to maxPageSize results rather than exactly that number.
它是为任何可选字段确定合理的默认值很重要,这样消费者在将可选字段留空时就不会感到惊讶。在这种情况下,为最大页面大小选择默认值将取决于返回资源的形状和大小。例如,如果每个项目都很小(例如,以字节而不是千字节为单位),则使用默认值每页 100 个结果可能是有意义的。如果每个项目都是几千字节,则使用默认的 10 或 25 个结果可能更有意义。一般来说,推荐的默认页面大小是 10 个结果。最后,要求消费者指定最大页面大小也是可以接受的;但是,这应该保留用于 API 服务器根本无法采用合理默认值的情况。
It’s important to decide on reasonable defaults for any optional field so that consumers aren’t surprised when they leave the optional field blank. In this case, choosing a default for the maximum page size will depend on the shape and size of the resources being returned. For example, if each item is tiny (e.g., measured in bytes rather than kilobytes), it might make sense to use a default of 100 results per page. If each item is a few kilobytes, it might make more sense to use a default of 10 or 25 results. In general, the recommended default page size is 10 results. Finally, it’s also acceptable to require that consumers specify a maximum page size; however, that should be reserved for situations where a reasonable default simply can’t be assumed by the API server.
虽然选择默认的最大页面大小很重要,但记录此默认值并尽可能在其余 API 中保持一致更为重要。例如,在没有充分理由的情况下为此字段使用各种默认值通常不是一个好主意。如果消费者了解到您的 API 倾向于默认为每页 10 个结果,那么如果该 API 的其他地方默认为 50 个结果是没有用的,那将会非常令人沮丧原因。
While picking a default maximum page size is important, it’s even more important to document this default value and remain consistent with it whenever possible across the rest of your API. For example, it’s generally a bad idea to use a variety of default values for this field without a good reason for each value. If a consumer learns that your API tends to default to 10 results per page, it will be very frustrating if elsewhere in that API the default is 50 results for no good reason.
明显地负的页面大小对 API 服务器没有意义,因此应该被拒绝,并显示一个错误,指出请求无效。但是如果请求指定最大页面大小为 30 亿怎么办?幸运的是,我们之前的设计依赖于最大页面大小而不是精确的页面大小,这意味着我们可以愉快地接受这些值,返回我们的 API 服务器能够在一定时间内生成的合理数量的结果,并保持一致与 API 定义。
Obviously a negative page size is meaningless to an API server, and as a result should be rejected with an error noting that the request is invalid. But what should we do if a request specifies a maximum page size of 3 billion? Luckily our previous design that relies on a maximum page size rather than an exact page size means that we can happily accept these values, return a reasonable number of results that our API server is capable of producing within some set amount of time, and remain consistent with the API definition.
换句话说,虽然拒绝超过特定限制的页面大小(例如,页面中超过 1,000 个结果)当然是可以接受的,但认为它们有效是完全可以的,因为我们总是可以返回比指定数量更少的结果,同时遵守我们与消费者。现在我们已经讨论了简单的部分,让我们转向更复杂的部分之一:页令牌。
In other words, while it’s certainly acceptable to reject page sizes above a certain limit (e.g., over 1,000 results in a page), it’s perfectly fine to consider them valid as we can always return fewer results than specified while sticking to our agreement with the consumer. Now that we’ve discussed the simple pieces, let’s move onto one of the more complex ones: page tokens.
所以到目前为止,我们已经注意到页面标记应该用作游标,但我们还没有阐明此标记的行为。让我们先看看关于页面标记如何工作的最简单但经常令人困惑的事情之一:我们如何知道我们已经完成。
So far we’ve noted that a page token should be used as a cursor, but we haven’t clarified the behavior of this token. Let’s start by looking at one of the simplest, yet often confusing things about how page tokens work: how we know we’re finished.
自从没有办法强制消费者继续发出后续请求,从技术上讲,消费者可以在消费者简单地决定不再对更多结果感兴趣的任何时间点终止分页。但是服务器如何指示没有更多的结果呢?在许多系统中,我们倾向于假设一旦我们得到一页未满的结果,我们就在列表的末尾。不幸的是,该假设不适用于我们的页面大小定义,因为页面大小是最大的而不是精确的。因此,我们不能再依赖于表示分页已完成的部分整页结果。相反,我们将依靠一个空页面令牌来传达此消息。
Since there is no way to force a consumer to continue making subsequent requests, pagination can technically be terminated by the consumer at any point in time where the consumer simply decides they’re no longer interested in more results. But how can the server indicate that there are no more results? In many systems we tend to assume that once we get a page of results that isn’t full we’re at the end of the list. Unfortunately, that assumption doesn’t work with our page size definition since page sizes are maximum rather than exact. As a result, we can no longer depend on a partially full page of results that indicates pagination is complete. Instead, we’ll rely on an empty page token to convey this message.
这可能会让消费者感到困惑,尤其是当我们认为返回一个空的结果列表和一个非空的页面标记是完全有效的时候!毕竟,没有返回任何结果但仍然被告知还有更多结果似乎令人惊讶。正如我们之前在决定跟踪最大页面大小时所了解的,这种类型的响应发生在请求达到时间限制但未找到任何结果并且无法确定不会找到进一步结果时。换句话说,{results: [], nextPageToken: 'cGFnZTE='}API 的响应是这样说的:“我尽职尽责地搜索了 200 毫秒,但一无所获。你可以使用这个从我离开的地方继续令牌。”
This can be confusing to consumers, particularly when we consider that it is perfectly valid to return an empty list of results along with a nonempty page token! After all, it seems surprising to get no results back but still be told there are more results. As we learned before when deciding to keep track of a maximum page size, this type of response happens when the request reaches the time limit without finding any results and cannot be certain that no further results will be found. In other words, a response of {results: [], nextPageToken: 'cGFnZTE='} is the way the API says, “I dutifully searched for up to 200 ms but found nothing. You can pick up where I left off by using this token.”
所以到目前为止,我们已经讨论了我们如何使用页面令牌来传达消费者是否应该继续通过资源进行分页,但我们没有谈到它的内容。我们到底在这个令牌中放了什么?
So far, we’ve discussed how we use a page token to communicate whether a consumer should continue paging through resources, but we’ve said nothing about its content. What exactly do we put into this token?
简而言之,令牌本身的内容应该是 API 服务器在遍历结果列表时需要从它停止的地方获取的任何内容。这可能是一些抽象的东西,比如代码中的序列化对象,或者更具体的东西,比如传递给关系数据库的限制和偏移量。例如,您可以简单地使用序列化的 JSON 对象eyJvZmZzZXQiOiAxMH0=,例如{"offset": 10}Base64 编码的对象。但更重要的是,此实现细节必须对消费者完全隐藏。这可能看起来没有必要,但它实际上是一个非常重要的细节。
Put simply, the content of the token itself should be anything the API server needs to pick up where it left off when iterating through a list of results. This could be something abstract like a serialized object from your code, or more concrete, like a limit and offset to pass along to a relational database. For example, you might simply use a serialized JSON object like eyJvZmZzZXQiOiAxMH0= which is {"offset": 10} Base64-encoded. What’s more important though is that this implementation detail must remain completely hidden from consumers. This might seem unnecessary, but it’s actually a very important detail.
无论您在页面令牌中放入什么,令牌的结构、格式或含义都应该对消费者完全隐藏。这意味着我们实际上应该加密内容,而不是对内容进行 Base64 编码,这样令牌本身的内容对消费者来说就完全没有意义了。原因很简单:如果消费者能够辨别此令牌的结构或含义,那么它就是 API 表面的一部分,必须被视为 API 本身的一部分。由于使用页面令牌的全部目的是允许我们在不改变消费者 API 表面的情况下更改引擎盖下的实现,因此公开它会阻止我们利用这种灵活性。本质上,如果我们允许消费者窥视这些页面标记,消费者。
Regardless of what you put into your page token, the structure, format, or meaning of the token should be completely hidden from the consumer. This means that rather than Base64 encoding the content, we should actually encrypt the content so that the content of the token itself is completely meaningless to consumers. The reason for this is pretty simple: if the consumer is able to discern the structure or meaning of this token, then it’s part of the API surface and must be treated as part of the API itself. And since the whole point of using a page token was to allow us to change the implementation under the hood without changing the API surface for consumers, exposing this prohibits us from taking advantage of that flexibility. In essence, if we allow consumers to peek inside these page tokens, we’re leaking the implementation details and may end up in a situation where we cannot change the implementation without breaking consumers.
现在我们已经研究了可以放入令牌中的内容,这种使令牌对消费者不透明的概念引出了一个问题:为什么我们使用字符串类型来表示令牌?为什么不使用数字或原始字节?
Now that we’ve looked at what can go into a token, this notion of keeping the token opaque to consumers begs the question: why do we use a string type to represent the token? Why not use a number or raw bytes?
鉴于我们需要存储加密数据,使用整数之类的数值显然是行不通的。另一方面,原始字节往往是存储加密值的好方法,但我们使用字符串主要是为了方便。事实证明,这些标记通常会以 URL 结尾(作为GETHTTP 请求中的参数) 以及 JSON 对象,它们都不能很好地处理原始字节。因此,最常见的格式是使用 Base64 编码的加密值作为 UTF-8 序列化传递细绳。
Given our need to store encrypted data, it becomes obvious that using a numeric value like an integer simply won’t work. On the flip side, raw bytes tend to be a great way to store an encrypted value, but we use a string primarily out of convenience. It turns out that these tokens will often end up in URLs (as parameters in a GET HTTP request) as well as in JSON objects, neither of which handle raw bytes very well. As a result, the most common format is to use a Base64-encoded encrypted value passed around as a UTF-8 serialized string.
所以到目前为止,我们关于数据分页的讨论都假定数据本身是静态的。不幸的是,这种情况很少见。我们不需要假设所涉及的数据是只读的,而是需要一种方法来分页数据,尽管它发生变化,添加新结果并删除现有结果。更复杂的是,不同的存储系统提供不同的查询功能,这意味着我们的 API 的行为可能与用于存储我们的数据的系统(例如 MySQL)紧密耦合。
So far, our discussion about paging through data has assumed that the data itself was static. Unfortunately this is seldom the case. Instead of assuming that the data involved is read-only, we need a way of paging through data despite it changing, with new results added and existing results removed. To further complicate the case, different storage systems provide different querying functionality, meaning that the behavior of our API may be tightly coupled to the system used to store our data (e.g., MySQL).
图 21.4 如果在分页过程中添加新数据,消费者可能会看到重复的结果。
Figure 21.4 A consumer might see duplicate results if new data is added in the middle of paging.
如您所料,这通常会导致令人沮丧的情况。例如,想象一次通过数据分页两个结果,而当这种情况发生时,其他人恰好在列表的开头添加了两个新资源(图 21.4)。如果您使用偏移量count(您的下一页标记表示从第 3 项开始的偏移量),添加到列表开头的两个新创建的资源将使您看到第 1 页两次。这是因为现在新页面 2 与旧页面 1 的开头有偏移量。此外,当对请求应用过滤器并且修改结果以使其加入或离开匹配项时,这种情况再次出现团体。在这种情况下,消费者可能会失去对您的 API 提供所有匹配结果以及仅提供匹配结果的信心。我们应该做什么?
As you’d expect, this can often lead to frustrating scenarios. For example, imagine paging through data two results at a time and while this is happening someone else happens to add two new resources at the start of the list (figure 21.4). If you are using an offset count (where your next page token represents an offset starting at item 3), the two newly created resources added at the start of the list will cause you to see page 1 twice. This is because what is now the new page 2 has the offset from the start of the old page 1. Further, this scenario shows up again when a filter is applied to the request and results are modified such that they either join or leave the matching group. In that case, consumers may lose confidence in your API to deliver all the matching results as well as only matching results. What should we do?
不幸的是,这个问题没有简单的答案。如果您的数据库支持数据的时间点快照(例如 Google Cloud Spanner 或 CockroachDB),则将此信息编码为页面令牌可能很有意义,这样您就可以保证分页高度一致。在许多情况下,数据库不允许高度一致的结果,在这种情况下,唯一的其他合理选择是在您的 API 文档中注明页面可能代表数据的“涂抹”,因为基础资源被添加、删除或修改(图 21.5)。如前所述,避免依赖数字偏移量并使用上次看到的结果作为光标也是一个好主意,这样您的下一页标记确实会在剩下的地方找到离开。
Unfortunately, there is no simple answer to this question. If your database supports point-in-time snapshots of the data (such as Google Cloud Spanner or CockroachDB), it may make sense to encode this information in page tokens such that you can guarantee strongly consistent pagination. In many cases databases will not allow strongly consistent results, in which case the only other reasonable option is to note in the documentation of your API that pages may represent a “smear” of the data as underlying resources are added, removed, or modified (figure 21.5). As noted, it’s also a good idea to avoid relying on a numeric offset and instead use the last seen result as a cursor so that your next page token really does pick up where things left off.
Figure 21.5 Page numbers may change over time as the underlying data changes.
什么时候我们考虑使用大量结果列表时,我们倾向于假设请求发生在某个相对较短的时间窗口内,通常以秒或分钟为单位。但是如果请求发生得更慢怎么办(如图 21.6 所示)?显然,请求一页数据和 10 年后的下一页可能有点荒谬,但 24 小时后就那么疯狂吗?60分钟呢?不幸的是,我们很少记录页面令牌的生命周期,这可能会导致一些棘手的情况。
When we think of consuming a large list of results, we tend to assume that the requests happen in some relatively short window of time, typically measured in seconds or minutes. But what if requests happen much more slowly (as seen in figure 21.6)? Obviously it might be a bit ridiculous to request a page of data and the next page 10 years later, but is 24 hours later all that crazy? What about 60 minutes? Unfortunately we rarely document the lifetime of page tokens, which can lead to some tricky situations.
Figure 21.6 Using page tokens from 10 years ago might be unreasonable.
通常,API 选择不定义页面令牌何时过期的限制。由于通过数据分页应该是幂等操作,因此遇到过期令牌的故障模式只需要重试。这意味着这里的失败只是一种不便。
Generally, APIs choose not to define limits of when page tokens might expire. Since paging through data should be an idempotent operation, the failure mode of running into an expired token just requires a retry. This means that failure here is simply an inconvenience.
也就是说,我们应该澄清令牌应该保持多长时间以设定消费者对其寻呼操作的期望。在将标准设置得较低的经典案例中,根据您的用例,将令牌过期时间设置得相对较短通常是个好主意。对于典型的 API,这可能以分钟(或最多几小时)为单位进行衡量,60 分钟的到期时间被认为是慷慨的大多数消费者。
That said, we should clarify how long tokens should remain valid to set consumer expectations about their paging operations. In a classic case of setting the bar low, it’s generally a good idea to set token expiration to be relatively short given your use case. For a typical API this is likely to be measured in minutes (or hours at the most), with a 60-minute expiration considered generous by most consumers.
最后,对结果进行分页时的一个常见问题是是否包括结果总数,通常在显示用户界面元素时使用,例如“184 个结果中的第 10-20 个”。当这个总数相对较小时(例如,如图所示的 184),这是一个很容易显示的统计数据。然而,当这个数字变得更大时(例如,4,932,493,534),计算这些结果的简单操作可能会变得非常计算密集,以至于不可能快速返回准确的计数。
Finally, a common question when paging through results is whether to include a total count of results, typically used when showing user interface elements like “Results 10–20 of 184.” When this total count is relatively small (e.g., 184 as shown), it’s an easy statistic to show. However, when this number gets much larger (e.g., 4,932,493,534), the simple act of counting those results could become so computationally intensive that it’s impossible to return an accurate count quickly.
在这一点上,我们只能选择是非常缓慢地提供准确的计数,还是相对较快地提供不准确的计数作为最佳猜测。这些选项都不是特别好,因此 API 通常不应该提供总结果计数,除非消费者有明确的需求(即,没有这个计数,他们就无法完成他们的工作)或者计数永远不会变得足够大以至于它落在这个情况。如果出于某种原因你绝对必须包括总数,它应该是一个名为totalResults附加到响应。
At that point we’re left to choose between providing an accurate count very slowly, or an inaccurate count as a best guess relatively quickly. None of these options is particularly good, so APIs generally should not provide a total result count unless there is a clear need to consumers (i.e., without this count they cannot do their job) or the count can never become large enough that it lands in this situation. If, for some reason you absolutely must include the total count, it should be an integer field named totalResults attached to the response.
Listing 21.2 Response interface including the total number of results
interface ListChatRoomsResponse { results: ChatRoom[]; nextPageToken: string; totalResults: number; ❶ }
❶使用 totalResults 字段来指示如果完成分页将包含多少结果。
❶ Use a totalResults field to indicate how many results would be included if pagination were completed.
现在我们了解了所有关于页面标记的知识,让我们看一个相对独特的情况,其中单个资源可能足够大以证明在单个资源内而不是跨多个资源集合进行分页是合理的。资源。
Now that we know all about page tokens, let’s look at a relatively unique case where single resources might be sufficiently large to justify pagination inside a single resource rather than across a collection of resources.
在在某些情况下,单个资源可能会变得非常大,可能会达到消费者希望能够分页浏览资源内容,通过多个请求将其构建为完整资源的程度。在这些场景中,我们可以将相同的原则应用于单个资源而不是资源集合,方法是将资源分成任意大小的块,并使用连续标记作为块中的游标。
In some scenarios single resources could become quite large, perhaps to the point where consumers want the ability to page through the content of the resource, building it up into the full resource over several requests. In these scenarios, we can apply the same principles to a single resource rather than a collection of resources by breaking the resource up into arbitrarily sized chunks and using continuation tokens as cursors through the chunks.
Listing 21.3 API definition for paging through single large resources
abstract class ChatRoomApi { @get("/{id=chatRooms/*/messages/*/attachments/*}:read") ReadAttachment(req: ReadAttachmentRequest): ReadAttachmentResponse; ❶ } interface ReadAttachmentRequest { id: string; pageToken: string; ❷ maxBytes: number; ❸ } interface ReadAttachmentResponse { chunk: Attachment; ❹ fieldMask: FieldMask; ❺ nextPageToken: string; ❻ }
❶ The custom read method allows us to consume a single resource in small bite-sized chunks.
❷ The continuation token from a previous response. If empty, it indicates a request for the first chunk of the resource.
❸给定块中返回的最大字节数。如果为空,则使用默认值,例如 1,024 字节。
❸ Maximum number of bytes to return in a given chunk. If empty, a default value of, say, 1,024 bytes is used.
❹ A single chunk of data belonging to the resource
❺ The list of fields with data provided in the chunk to be appended to the resource
❻请求下一个资源块时使用的令牌。如果为空,则表示这是最后一个块。
❻ The token to use when requesting the next chunk of the resource. If empty, it indicates that this is the final chunk.
在此设计中,我们假设单个资源的读取可以是强一致的,这意味着如果在我们通过其数据分页时修改了资源,则应该中止请求。它还依赖于在多个请求上构建资源的想法,其中每个响应包含要附加到到目前为止已构建的任何数据的一些数据子集,如图 21.7 所示。我们还依靠字段掩码来明确传达ChatRoom接口的哪些字段有数据要在这个给定的通道中使用未知。
In this design, we assume that a read of a single resource can be strongly consistent, which means that if the resource is modified while we are paging through its data, the request should be aborted. It also relies on the idea of building up a resource over several requests where each response contains some subset of the data to be appended to whatever data has been built up so far, shown in figure 21.7. We also rely on the field mask to explicitly convey which fields of the ChatRoom interface have data to be consumed in this given chunk.
Figure 21.7 Example flow of paging through a single large resource
现在我们已经了解了这个模式的所有细微差别,最终生成的 API 定义显示在清单 21.4 中,涵盖了跨资源集合的分页以及内部的分页单身的资源。
Now that we’ve gone through all of the nuances of this pattern, the final resulting API definition is shown in listing 21.4, covering both pagination across a collection of resources as well as inside a single resource.
Listing 21.4 Final API definition
abstract class ChatRoomApi { @get("/chatRooms") ListChatRooms(req: ListChatRoomsRequest): ListChatRoomsResponse; @get("/{id=chatRooms/*/messages/*/attachments/*}:read") ReadAttachment(req: ReadAttachmentRequest): ReadAttachmentResponse; } interface ListChatRoomsRequest { pageToken: string; maxPageSize: number; } interface ListChatRoomsResponse { results: ChatRoom[]; nextPageToken: string; } interface ReadAttachmentRequest { id: string; pageToken: string; maxBytes: number; } interface ReadAttachmentResponse { chunk: Attachment; fieldMask: FieldMask; nextPageToken: string; }
现在我们已经看到了这种模式产生的 API,让我们看看我们失去了什么,从分页方向开始。
Now that we’ve seen the API resulting from this pattern, let’s look at what we lose out, starting with paging direction.
一显然,这种模式无法实现的是双向工作的能力。换句话说,此模式不允许您从当前位置向后翻页以查看之前看到的结果。虽然这对于允许浏览结果的面向用户的界面可能不方便,但对于编程交互而言,它不太可能成为真正必要的功能。
One obvious thing that is not possible with this pattern is the ability to work in both directions. In other words, this pattern doesn’t allow you to page backward from the current position to look at results previously seen. While this might be inconvenient for user-facing interfaces that allow browsing through results, for programmatic interaction it’s very unlikely to be a truly necessary feature.
如果用户界面确实需要这种能力,一个不错的选择是使用 API 构建结果缓存,然后允许界面在该缓存中任意移动。这具有避免分页问题的额外好处一致性。
If a user interface truly needs this ability, one good option is to use the API to build a cache of results and then allow the interface to move arbitrarily through that cache. This has the added benefit of avoiding issues with paging consistency.
相似地,此模式不提供导航到资源列表中特定位置的能力。换句话说,没有办法专门要求第 5 页,原因很简单,页码的概念不存在。消费者不应依赖页码,而应使用过滤器和排序来导航到一组特定的匹配资源。同样,这往往是允许浏览的面向用户的界面所需的功能,而不是程式化的相互作用。
Similarly, this pattern does not provide an ability to navigate to a specific position within the list of resources. In other words, there’s no way to specifically ask for page 5 for the very simple reason that the concept of a page number doesn’t exist. Instead of relying on page numbers, consumers should instead use filters and ordering in order to navigate to a specific matching set of resources. Again, this tends to be a feature required for user-facing interfaces that allow browsing rather than a requirement for programmatic interaction.
对于吨为了完整起见,让我们简要地看一下通常应该避免的这种模式精神的简单(并且很常见)实现。鉴于大多数关系数据库都支持OFFSET和LIMIT关键字,因此通常很想在 API 中将其作为在资源列表上公开窗口的一种方式来继承。换句话说,我们不是请求某个块,而是通过请求从某个偏移量开始的数据并将结果的大小限制在某个数字来选择一个特定的块,如图 21.8 所示。
For the sake of completeness, let’s look briefly at a simple (and quite common) implementation of the spirit of this pattern that should generally be avoided. Given that most relational databases support the OFFSET and LIMIT keywords, it’s often tempting to carry that forward in an API as a way of exposing a window over a list of resources. In other words, instead of asking for some chunk we choose a specific chunk by asking for the data starting at a certain offset and limiting the size of the result to a certain number, shown in figure 21.8.
Figure 21.8 Defining a window using offets and limits
在 API 表面中打开时,这会导致向请求添加两个额外的字段,这两个都是指定起始偏移量和限制的整数。通过将结果数添加到先前请求的偏移量(例如, ),可以轻松计算出下一个偏移量nextOffset = offset + results.length。此外,我们可以使用不完整的页面作为终止条件(例如,hasMoreResults = (results.length != limit)).
When turned in an API surface, this results in two additional fields added to the request, both integers that specify the start offset and the limit. The next offset can be easily computed by adding the number of results to the previously requested offset (e.g., nextOffset = offset + results.length). Further, we can use an incomplete page as the termination condition (e.g., hasMoreResults = (results.length != limit)).
Listing 21.5 API surface using offset and limit
abstract class ChatRoomApi { @get("/chatRooms") ListChatRooms(req: ListChatRoomsRequest): ListChatRoomsResponse; } interface ListChatRoomsRequest { ❶ offset: number; ❶ limit: number; ❶ } ❶ interface ListChatRoomsResponse { results: ChatRoom[]; }
❶为了支持资源分页,我们包含了 offset 和 limit 参数。
❶ To support paging through resources, we include offset and limit parameters.
当我们将其转换为我们的底层数据库时,基本上没有任何工作要做:我们只需获取提供的偏移量和限制参数(例如,对 的GET请求https://example.org/chatRooms/5/messages?offset=30&limit=10)并将它们与我们的查询一起传递。
When we translate this to our underlying database, there’s basically no work to be done: we simply take the provided offset and limit parameters (e.g., a GET request to https://example.org/chatRooms/5/messages?offset=30&limit=10) and pass them along with our query.
清单 21.6 使用 SQL LIMIT 和 OFFSET 的分页
Listing 21.6 Pagination using SQL LIMIT and OFFSET
SELECT * FROM messages WHERE chatRoomId = 5 OFFSET 30 ❶ LIMIT 10 ❷
❶ OFFSET 修饰符指定页码(在这种情况下,偏移量为 30,每页 10 个结果指向第 4 页)。
❶ The OFFSET modifier specifies the page number (in this case an offset of 30 with 10 results per page points to page 4).
❷ LIMIT 修饰符指定页面大小(在本例中,每页 10 个结果)。
❷ The LIMIT modifier specifies the page size (in this case, 10 results per page).
这种模式的根本问题在于它会将实现细节泄露给 API,因此无论底层存储系统如何,该 API 都必须继续支持偏移量和限制。这现在看起来可能没什么大不了的,但是随着存储系统变得越来越复杂,使用限制和偏移量的实现可能并不总是有效。例如,如果您的数据存储在最终一致的分布式系统中,那么随着偏移值的增加,寻找偏移量的起点实际上可能变得越来越昂贵。
The fundamental problem with this pattern is that it leaks the implementation details to the API, so this API must continue to support offsets and limits regardless of the underlying storage system. This may not seem like a big deal now, but as storage systems become more complex, implementations using limits and offsets may not always work. For example, if your data is stored in an eventually consistent distributed system, finding the starting point of an offset might actually become more and more expensive as the offset value increases.
这种模式也存在与一致性相关的问题。在此示例中,如果添加了一些新结果,它们可能会导致响应返回之前已经看到的结果页。
This pattern also suffers from problems related to consistency. In this example, if some new results are added, they may cause the response to return results that were already seen in a previous page.
Why is it important to use a maximum page size rather than an exact page size?
What should happen if the page size field is left blank on a request? What if it’s negative? What about zero? What about a gigantic number?
Is it reasonable for page tokens for some resource types to have different expirations than those from other resource types?
Why is it important that page tokens are completely opaque to users? What’s a good mechanism to enforce this?
分页允许大量结果集合(或大型单一资源)在一系列小块中使用,而不是作为使用三个特殊字段的单个大型 API 响应:maxPageSize、pageToken和nextPageToken。
Pagination allows large collections of results (or large single resources) to be consumed in a series of bite-sized chunks rather than as a single large API response using three special fields: maxPageSize, pageToken, and nextPageToken.
如果有更多页面,响应将包含一个nextPageToken值,可以在pageToken后续请求的字段中提供该值以获取下一页。
If there are more pages, a response will include a nextPageToken value, which can be provided in the pageToken field of a subsequent request to get the next page.
我们依赖于最大页面大小而不是确切的页面大小,因为我们不知道准确填充页面需要多长时间,并且必须保留即使在结果页面完全填充之前返回的能力。
We rely on a maximum page size rather than an exact page size as we don’t know how long it will take to fill a page exactly and must reserve the ability to return even before a page of results is fully populated.
Paging is complete when a response has no value for the next page token (not when the results field is empty).
虽然标准列表方法提供了一种机制来遍历 API 中的一组完整资源,但到目前为止,我们确实没有办法表明只对这些资源的一个子集感兴趣。此模式将探索使用标准列表请求中的特殊字段作为过滤完整数据集以仅返回匹配结果集的方法。此外,我们将详细介绍如何为这个特殊过滤字段构建输入值。
While the standard list method provides a mechanism to iterate through a complete set of resources in an API, so far we really don’t have a way to indicate an interest in only a subset of those resources. This pattern will explore using a special field on the standard list request as a way to filter the full data set to return only a matching result set. Additionally, we’ll get into the details of how to structure input values for this special filtering field.
到目前为止,检索大量资源的典型方法非常简单:使用标准列表方法。类似地,如果我们正在寻找一个特定的资源并且碰巧知道它的标识符,我们有一个同样简单的工具可供我们使用:标准的 get 方法。但是,如果我们的目标介于两者之间呢?如果我们想浏览恰好符合一组特定条件的资源怎么办?我们既不寻找单个资源,也不打算浏览所有资源,但我们确实对要搜索的资源有一些了解。我们该如何处理?
So far, the typical way of retrieving lots of resources has been pretty straightforward: use the standard list method. Similarly, if we’re looking for a specific resource and happen to know its identifier, we have an equally straightforward tool at our disposal: the standard get method. But what if our goals fall somewhere in between? What if we want to browse through resources that happen to match a specific set of criteria? We’re neither looking for an individual resource, nor are we aiming to browse through all resources, but we do have some idea of the resources we’re searching for. How can we handle this?
不幸的是,到目前为止还没有很好的方式来表达这个中间立场。相反,我们在技术上能做的最好的事情是建立在标准列表方法的基础上,并在资源列表返回时应用一组过滤条件。
Unfortunately, so far there’s no good way to express this middle ground. Instead, the best we can technically do is build on the standard list method and apply a set of filtering criteria over the list of resources as they’re returned.
Listing 22.1 Example of filtering resources on the client side
async function ListMatchingChatRooms(title?: string): Promise<ChatRoom[]> { let results: ChatRoom[] = []; let response: ListChatRoomsResponse; while (response === undefined || response.nextPageToken) { ❶ response = await ListChatRooms({ ❷ pageToken: response.nextPageToken }); for (let chatRoom of response.resources) { ❸ if (title === undefined || chatRoom.title === title) { ❸ results.push(chatRoom); } } } return results; }
❶ Loop on the first iteration as long as there’s a next page of resources to view
❷ Fetch the page of resources from the API.
❸ Loop through each resource and check for a match of the title field.
正如您想象的那样,这种设计远非理想。最明显的问题是,为了确定我们已经找到所有匹配的结果,客户端必须针对存储在 API 中的整个资源集获取、迭代和评估过滤条件。对于没有匹配项的边缘情况,这尤其令人沮丧,因为我们必须过滤资源才发现它们都不符合过滤条件。但单就数据传输消耗而言,显然是过度浪费了。我们如何解决这个问题?
As you might imagine, this design is far from ideal. The most glaring issue is that in order to be certain we’ve found all matching results, the client must fetch, iterate, and evaluate the filter criteria against the entire set of resources stored in the API. This is particularly frustrating with edge cases where there are no matches, as we must filter through the resources only to discover that none of them meet the filter criteria. But in data transportation consumption alone, it’s obvious that this is excessively wasteful. How do we address this issue?
幸运的是,这个难题的解决方案非常明显。与其检索所有可用资源并过滤那些我们不感兴趣的资源,不如让我们反过来把这个责任推给 API 服务器本身。换句话说,我们所要做的就是向 API 服务器声明要匹配的条件,然后结果集将只包含匹配的资源。
Luckily, the solution to this conundrum is pretty obvious. Rather than retrieving all the resources available and filtering those we’re not interested in, let’s flip things around instead and push this responsibility onto the API server itself. In other words, all we have to do is declare to the API server the criteria to match against and then the result set will contain only the matching resources.
Listing 22.2 Additional filter field on the standard list request
interface ListChatRoomsRequest { filter: any; ❶ maxPageSize: number; ❷ pageToken: string; ❷ }
❶ This is listed as any right now because we haven’t decided how we’ll implement this.
❷ These fields relate to the pagination pattern (chapter 21).
虽然这个设计概念可能很明显,但实现本身有点复杂。列出资源时我们应该向服务器传达某种过滤条件很容易,但过滤数据实际上应该是什么样的呢?它应该是类似于 SQL 查询的简单字符串吗?或者更复杂的结构来匹配字段,比如 MongoDB 查询过滤器文档?
While this design concept might be obvious, the implementation itself is a bit more complicated. It’s easy to state that we should communicate some sort of filtering criteria to the server when listing resources, but what should that filter data actually look like? Should it be a simple string resembling a SQL query? Or a more complex structure for matching fields like a MongoDB query filter document?
一旦我们选择了可接受的格式来传达查询,我们就必须考虑应该支持哪些功能。例如,我们是否应该只允许匹配资源字段(例如,匹配ChatRoom资源的确切的标题)?或者我们应该用通配符进一步扩展它(例如,匹配标题中任意位置的关键字)?甚至任意文本搜索,如搜索查询(例如,出现在任何字段中的关键字)?
Once we’ve chosen an acceptable format for communicating the query, we then have to consider what functionality should be supported. For example, should we allow matches on resource fields only (e.g., matching a ChatRoom resource’s exact title)? Or should we expand this further with wildcards (e.g., matching a keyword anywhere in the title)? Or even arbitrary text searches, like a search query (e.g., a keyword appearing in any of the fields)?
查询没有专门存储在资源上的东西怎么样?例如,我们是否应该允许基于字段的元数据进行查询,例如 a 的成员数ChatRoom?这是否为扩展到相关资源的查询打开了大门?例如,过滤器是否应该提供一种方法来将ChatRoom结果限制为仅具有名为 Joe 的成员的那些人?或者只有那些有一个叫 Joe 的成员,而这个成员是另一个ChatRoom有一个叫 Luca 的成员的成员?如果是这样,这种能力能延伸多远?
What about querying things that aren’t specifically stored on the resource? For example, should we allow querying based on metadata about fields, such as the number of members of a ChatRoom? And does this open the door to queries extending into related resources? For instance, should a filter provide a way to limit ChatRoom results to only those who have a member named Joe? Or only those with a member named Joe who is a member of another ChatRoom with a member named Luca? If so, how far does this ability extend?
正如这些示例有望表明的那样,这可能很快就会成为一个需要探索的非常深的洞穴,有许多不同的曲折,并不是所有的都值得在过滤规范中得到支持。该模式的目标是解决这些关键问题,特别是如何指定过滤器以及确切的功能价值支持。
As these examples hopefully make clear, this could quickly become a very deep cave to explore, with many different twists and turns, not all of which are worth supporting in a filtering specification. The goal of this pattern will be to address these key concerns, particularly how to specify a filter and exactly what functionality is worth supporting.
这我们需要做的第一件事是决定如何最好地将代表用户意图的过滤器传达给 API 服务器以供执行。换句话说,我们可以用很多不同的方式来表示相同的过滤意图,但重要的是我们选择一种方式,以便在支持过滤的标准列表方法中一致地使用。什么格式最好?
The first thing we need to do is decide how best to communicate a filter representing the user’s intent to the API server for execution. In other words, there are lots of different ways in which we can represent the same filtering intent, but it’s important that we settle on a single way to use consistently across standard list methods that support filtering. What format is best?
只是因为我们对 API 中的序列化格式有很多不同的选择(例如 JSON [ https://www.json.org/json-en.html ] 与协议缓冲区 [ https://developers .google.com/protocol -buffers ] 与 YAML [ https://yaml.org/ ]),我们同样有许多不同的选择,用于在列出资源时如何表示过滤器。通常,这些选择分为两个不同的类别:结构化和非结构化。
Just as we have lots of different choices for the serialization format in an API (such as JSON [https://www.json.org/json-en.html] versus protocol buffers [https://developers .google.com/protocol-buffers] versus YAML [https://yaml.org/]), we similarly have many different choices for how we’ll represent filters when listing resources. Generally, these choices fall into two distinct categories: structured and unstructured.
在非结构化方面,我们有 SQL 查询之类的东西,其中过滤器表示为字符串值,恰好符合非常特定的语法。这个字符串值然后由 API 服务器解析并评估以确保只有匹配的资源返回给使用河
On the unstructured side of the spectrum we have things like SQL queries, where a filter is represented as a string value, which just so happens to conform to a very specific syntax. This string value is then parsed by the API server and evaluated to ensure only matching resources are returned to the user.
清单 22.3 用于过滤 ChatRoom 资源的示例 SQL 查询
Listing 22.3 Example SQL query for filtering ChatRoom resources
SELECT * FROM ChatRooms WHERE title = "New Chat!";
SELECT * FROM ChatRooms WHERE title = "New Chat!";
在非结构化方面,我们将一些责任从 API 服务器推回给客户端,有效地要求客户端提前将查询解析为特殊模式,然后将这个复杂的接口直接发送给 API 服务器进行评估(需要很少或不需要解析内容)。虽然不像无处不在的 SQL 风格的字符串值那么常见,但仍有许多系统(如 MongoDB)依赖这些结构化接口进行过滤。
On the unstructured side, we push some of the responsibility away from the API server and back onto the client, effectively requiring the client to parse the query ahead of time into a special schema and then send this complex interface directly to the API server for evaluation (requiring little or no parsing of the content). While less common than the ubiquitous SQL-style string value, there are still many systems, such as MongoDB, that rely on these structured interfaces for filtering.
清单 22.4 用于过滤 ChatRoom 资源的示例 MongoDB 结构化查询
Listing 22.4 Example MongoDB structured query for filtering ChatRoom resources
db.chatRooms.find({ title: "New Chat!" } );
db.chatRooms.find( { title: "New Chat!" } );
但这给我们带来了一个基本问题:这两者中哪一个是正确的?在我们深入讨论每个选项的优缺点之前,重要的是要记住,这个决定在很多方面都是肤浅的。换句话说,每个选项的最终结果可能看起来不同,但每个选项都应该能够完成相同的事情。事实上,从一种格式转换为另一种格式实际上应该是简单直接的,就像将 JSON 序列化资源转换为 YAML 格式资源一样容易。然而,在这种情况下,转换依赖于序列化从结构化表示转换为字符串并在另一个方向进行解析时,有点像JSON.stringify()和JSON.parse()在 Node.js 中,如图 22.1 所示。
But this brings us to a fundamental question: which of these two is right? Before we get deep into discussion over the benefits and drawbacks of each of these options, it’s important to remember that this decision is, in many ways, superficial. In other words, the end result of each option might look different, but each should be capable of accomplishing the same things. In fact, it should actually be simple and straightforward to transform from one format to another, sort of how it’s easy to translate from a JSON-serialized resource to a YAML-formatted resource. In this case, however, the transformation relies on serialization when converting from a structured representation to a string and parsing when going in the other direction, a bit like JSON.stringify() and JSON.parse() in Node.js, shown in figure 22.1.
Figure 22.1 Relationship between string queries and structured queries
由于这两个选项在功能上是等效的,因此在两者之间做出选择实际上变成了可用性和未来灵活性的问题。换句话说,既然两种选择都可以满足相同的功能需求,那么我们不得不问:哪个更容易被客户使用,哪个更经得起时间的考验?
Since these two options are functionally equivalent, making a choice between the two really becomes a question of usability and future flexibility. In other words, since both options can be made to satisfy the same functional requirements, we have to ask: which will be easier for clients to use and which will better survive the test of time?
作为 API 设计者,我们在这种情况下的第一直觉几乎总是尝试一种可重用、灵活且简单的设计来表示过滤器。尽管这是一个勇敢的目标,但不幸的是,由于输入必须遵循结构化模式这一事实,即使是最好的设计也仍然存在一些关键缺陷。
As API designers, our first instinct in this scenario is almost always to attempt a reusable, flexible, and simple design for representing filters. And while this is a valiant goal, unfortunately even the best design possible will still suffer from a few key drawbacks due to the fact that the input must adhere to a structured schema.
要考虑的主要问题是,最终,这些过滤器与我们可能用任何编程语言编写的任何其他代码没有什么不同。过滤器本身肯定比一些任意代码更受限制和沙盒化,但如果我们仔细观察我们在这里所做的事情,从根本上讲,我们实际上只是用用户提供的代码填充特定的函数定义。然后可以在filter()函数中使用此代码在一系列资源上确定要包括哪些和要排除哪些。
The main issue to consider is that, ultimately, these filters are no different from any other code we might write in any programming language. A filter itself is certainly more limited and sandboxed than some arbitrary code, but if we look closely at what we’re doing here, at a fundamental level we’re really just filling in a specific function definition with user-provided code. This code might then be used in a filter() function on an array of resources to determine which to include and which to exclude.
Listing 22.5 An example of how filter expression code should be evaluated
function resourceMatchesFilter(resource: Object): boolean { if (/* user-provided code here! */) { return true; } else { return false; } }
为了了解为什么尝试为过滤定义结构化表示没有多大意义,让我们想象一下,我们实际上接受了一个可能充当清单 22.5 中 if 语句条件的小过滤器表达式,而不是要评估的任意函数。在这种情况下,我们正在评估用户提供的代码,为这个任意输入定义一个模式真的是个好主意吗?或者接受一些字节作为输入并像大多数编译器那样做更有意义:对内容应用语法规则以验证它确实是有效代码?我们仍然使用基于文本的文件而不是一些结构化模式来定义函数来表示我们的程序应该做什么是有原因的——过滤器也不例外。但故事并没有就此结束。
To see why it doesn’t quite make sense to try to define a structured representation for filtering, let’s imagine that, instead of accepting a small filter expression that might act as the condition of the if-statement in listing 22.5, we actually accepted an arbitrary function to be evaluated. In this case, where we’re evaluating user-provided code, does it really seem like a good idea to define a schema for this arbitrary input? Or would it make more sense to accept some bytes as input and do as most compilers do: apply syntax rules over the contents to validate that it is, indeed, valid code? There’s a reason we still define functions using text-based files and not some structured schema to represent what our programs should do—and filters are no different. But the story doesn’t end there. String filters stand out as the better choice for a few more reasons.
首先,当使用字符串值时,语法规则仅由 API 服务器强制执行和验证。这意味着任何更改或改进(例如,新功能)都由 API 服务器本身管理,因此不需要客户端进行更改。正如我们在第 24 章中了解到的那样,无需客户进行任何更改或工作即可进行改进的能力可能是一个非常强大的工具。
First, when using a string value, the syntax rules are enforced and validated exclusively by the API server. This means that any changes or improvements (e.g., new functionality) are managed by the API server itself and therefore won’t require changes by the client. As we’ll learn in chapter 24, the ability to make improvements without requiring any changes or work from the client can be a very powerful tool.
其次,字符串查询通常为那些将使用 API 的人所熟知和理解,特别是如果他们曾经使用过某种 SQL 风格的关系数据库。这意味着字符串查询可能更容易让用户立即理解,因为它依赖于他们已经熟悉的概念。
Second, string queries are generally well known and understood by those who will be using the API, particularly if they’ve ever used a relational database that speaks some flavor of SQL. This means that a string query is likely to be much easier for a user to understand right off the bat, as it relies on concepts they’re already familiar with.
最后,这可能会令人惊讶,但随着过滤标准变得更加复杂,即使是设计得最好的结构也往往会变得有点难以遵循。为了解这在实际示例中的效果,表 22.1 显示了与 MongoD 中的结构化过滤器对象相比,各种不同的过滤条件如何在 SQL 中表示为字符串B.
Finally, it might be surprising, but even the best designed structures tend to get a bit difficult to follow as the filtering criteria become more complicated. To see how this plays out in a real-world example, table 22.1 shows how a variety of different filter conditions might be represented as strings in SQL compared to a structured filter object in MongoDB.
表 22.1 SQL 字符串查询与 MongoDB 过滤器结构
Table 22.1 SQL string queries versus MongoDB filter structures
如您所见,虽然这两个选项都能够表达相同的布尔条件,但对于我们中的许多人来说,SQL 风格的字符串往往更容易理解和处理。这可能是因为这些字符串实际上看起来很像我们可能用典型编程语言编写的布尔表达式。换句话说,我们越来越依赖于用户已经熟悉的东西,而不是期望并要求他们专门为我们的 API 学习一些新东西。
As you can see, while both options are capable of expressing the same Boolean conditions, the SQL-style strings tend to be a bit easier to follow and mentally process for many of us. This is probably because these strings actually look quite a lot like the Boolean expressions we might write in a typical programming language. In other words, we’re relying more and more on things that users are already familiar with rather than expecting and requiring them to learn something new specifically for our API.
因此,虽然用于表示过滤条件的结构化接口肯定会起作用,但随着时间的推移,它更有可能导致更脆弱的 API 定义。此外,自定义结构化接口几乎总是伴随着更陡峭的学习曲线,并且与序列化字符串表示相比没有太多好处。因此,尽管尝试设计一个出色的结构来表示这些过滤器可能很诱人,但随着服务本身随时间的发展和变化,依赖可以随着 API 一起增长的字符串字段几乎总是更安全。
So while a structured interface for representing filter criteria would certainly work, it is far more likely to result in a more brittle API definition that suffers as time goes on. Additionally, custom structured interfaces almost always come with a steeper learning curve, and without much benefit over a serialized string representation. As a result, even though it might be tempting to try designing a fantastic structure for representing these filters, it’s almost always safer to rely on a string field that can grow with your API as the service itself evolves and changes over time.
现在我们已经说明了为什么非结构化值(例如字符串)是表示过滤器的更好选择,让我们深入研究我们应该使用的基础语法细绳。
Now that we’ve made the case for why an unstructured value (e.g., a string) is the better choice to represent a filter, let’s dig into the underlying syntax we should use for that string.
只是因为我们调用的是字符串,所以非结构化选项并不意味着这些字符串值没有任何规则。相反,字符串的非结构化性质指的是数据类型和模式,而不是值本身的格式,它必须符合一组特定的规则才能有效。但是这些规则是什么?过滤器字符串的正确语法是什么?
Just because we’re calling a string the unstructured option doesn’t mean that these string values come with no rules whatsoever. On the contrary, the unstructured nature of a string refers to the data type and schema rather than the format of the value itself, which must conform to a specific set of rules in order to be valid. But what are these rules? What is the proper syntax for a filter string?
设计和指定一种用于过滤资源的完整语言可能很有趣,但这可能超出了本书的范围。事实上,有很多语言规范可以实现这个目标,例如通用表达式语言(CEL;https://github.com/google/cel-spec/blob/master/doc/langdef.md), JSONQuery ( https://www.sitepen.com/blog/jsonquery-data-querying-beyond-jsonpath )、RQL ( https://github.com/persvr/rql ) 或 Google 的过滤规范 ( https://google .aip.dev/160). 在某些(但不是全部)情况下,尝试依赖底层存储系统的查询能力甚至可能是有意义的,例如强制查询语法遵循等效于 SQLWHERE子句的特定子集,对允许的功能有很多限制。
As fun as it might be to design and specify a complete language for filtering resources, that might be getting a bit too far outside the scope of this book. In fact, there are quite a few language specifications out there that accomplish this goal, such as Common Expression Language (CEL; https://github.com/google/cel-spec/blob/ master/doc/langdef.md), JSONQuery (https://www.sitepen.com/blog/jsonquery-data-querying-beyond-jsonpath), RQL (https://github.com/persvr/rql), or Google’s Filtering specification (https://google.aip.dev/160). And in some (but not all) cases it might even make sense to attempt to rely on the underlying storage system’s querying abilities, for example enforcing that the query syntax adheres to a specific subset of the equivalent of a SQL WHERE clause, with quite a few restrictions on allowed functionality.
与其从头开始重新发明轮子,或者甚至规定特定的“正确”语法,不如让我们研究一些最有可能导致最佳结果的特征和限制,而不管具体的实现和语法如何。
Instead of potentially reinventing the wheel from scratch, or even stipulating a particular “correct” syntax, let’s instead go through some of the characteristics and restrictions that are most likely to lead to the best results, regardless of the specific implementation and syntax.
甚至尽管过滤表达式应该只由简单的比较(例如,title = "New Chat!")组成,但事实证明,这些简单的比较实际上会变得非常复杂。如果你拿起一本关于各种数据库的 SQL 语法和功能的书,你会惊讶地发现这些类型的简单语句会变得多么复杂。例如,如果我们将比较的右侧从文字值(例如,特定字符串,如"New Chat!")更改为另一个变量(例如,title = description),会怎样?或者,如果我们走得更远并涉及不同的资源(例如,类似的资源administrator.employer.name = "Apple")怎么办?我们在哪里划定简单的过滤条件和对于我们的过滤语言而言太复杂的条件之间的界限?
Even though filtering expressions should be made up of nothing more than simple comparisons (e.g., title = "New Chat!"), it turns out that these simple comparisons can actually get quite complicated. If you pick up a book on SQL syntax and functionality of various databases, it can be surprising to see just how complex these types of simple statements can get. For example, what if we change the right side of a comparison from a literal value (e.g., a specific string like "New Chat!") to another variable (e.g., title = description)? Or what if we go even further and involve a different resource (e.g., something like administrator.employer.name = "Apple")? Where do we draw the line between a simple filter condition and something that’s too complex for our filtering language?
在决定支持什么功能时,我们考虑的第一件事比令人兴奋的更实用:执行评估函数的运行时间。这意味着我们必须回想一下我们作为计算机科学本科生的日子,他们被问及大 O 符号和确定运行函数的最坏情况。在这种情况下,我们担心是否存在可能导致评估函数异常缓慢或计算量大的给定过滤器字符串。举一个荒谬的例子,假设我们提供了一种方法来过滤从月球上的服务器获取的数据或需要对非常大的数据库中的所有行进行全面扫描的数据。显然,这会导致过滤速度极慢,从而导致整体体验不佳。幸运的是,
The first thing we consider when deciding what functionality to support is much more practical than exciting: the run time of executing the evaluation function. This means we must think back to our days as undergraduate computer science students being quizzed on big-O notation and determining the worst-case scenarios for running functions. And in this case, we’re concerned about whether there are given filter strings that might cause an evaluation function to be extraordinarily slow or computationally intensive. For an absurd example, imagine that we provided a way to filter against data fetched from a server on the moon or data that required a full scan of all rows in a very large database. Obviously this would lead to extremely slow filtering, which leads to a poor experience overall. Luckily, there’s a simple strategy to ensure that the run time of the evaluation function remains reasonable: limit the data available as inputs.
要遵循的一般准则是过滤器应该只需要单个资源的上下文才能成功评估过滤条件。这意味着如果我们考虑过滤器评估函数,它只会将两个东西作为输入参数:过滤器字符串本身和可能匹配或不匹配过滤器条件的资源。这意味着没有办法包含任何不属于资源的额外数据(例如,从月球获取的数据).
A general guideline to follow is that filters should only ever require the context of a single resource to evaluate a filter condition successfully. This means that if we were to consider the filter evaluation function, it would take as input parameters only two things: the filter string itself and the resource that may or may not match the filter criteria. This means there’s no way to include any extra data that isn’t part of the resource (e.g., data fetched from the moon).
Listing 22.6 An evaluation function with just a filter and a potential match
function evaluate(filter: string, resource: ChatRoom): boolean { // ... }
相反,如果我们允许不止一个资源用于评估(如清单 22.7 所示),我们将有很多额外的问题需要回答。例如,我们应该允许无限数量的额外资源用于比较还是将数量限制为其他(例如,五)?我们如何将这些信息加载到上下文中?我们应该允许不同类型的资源还是将它们限制为与正在评估的资源相同的类型?我们还有更复杂的性能影响,例如是否预取或缓存可能用于评估的资源信息,以及与资源新鲜度和数据一致性相关的新问题。
If, instead, we permitted more than just a single resource for evaluation (shown in listing 22.7), we’d have quite a few additional questions to answer. For example, should we allow unlimited numbers of additional resources for comparison or limit the number to something else (e.g., five)? How do we load this information into the context? Should we allow resources of different types or restrict them to only the same type as the resource being evaluated? We also have more complicated performance implications such as whether to prefetch or cache resource information that might be used for evaluation, as well as new problems related to resource freshness and data consistency.
Listing 22.7 An evaluation function that supports additional context
function evaluate(filter: string, resource: ChatRoom, context: Object[]): boolean { // ... }
虽然解决所有这些问题当然是可能的,但这样做所获得的价值不太可能值得付出努力。换句话说,虽然扩大过滤器表达式中可用于比较的变量范围有时可能会派上用场,但如果我们坚持过滤的真正目标,即限制基于结果的结果,这远非最常见的必要事情关于目标资源本身的简单条件。因此,它几乎总是避免回答这些问题的最佳选择。相反,我们可以简单地要求过滤条件只接受两条信息作为输入:过滤表达式本身和可能与表达式匹配或不匹配的单个资源。
While it’s certainly possible to solve for all of these problems, it’s unlikely that the value gained by doing so would be worth the effort. In other words, while expanding the scope of variables available for comparison in a filter expression might come in handy from time to time, it’s far from the most common thing necessary if we stick to the true goal of filtering, which is to limit results based on simple conditions about the target resources themselves. As a result, it’s almost always the best option to avoid having to answer these questions at all. Instead we can simply require that the filter conditions only accept as input two pieces of information: the filter expression itself and a single resource that may or may not match the expression.
虽然这是一个好的开始,但它并没有解决所有潜在的问题。尽管我们已经决定过滤器语法应该要求在评估中只涉及一个资源,但我们还没有说明过滤器表达式在评估期间可能实际做什么。例如,是什么阻止过滤器表达式引用另一个资源,使用起始资源中的关系来这样做?换一种说法,我们是否应该允许过滤器表达式引用其他资源并从其他地方(例如数据库)检索这些资源以评估表达式?
While that’s a good start, it doesn’t address all the potential concerns. Even though we’ve decided the filter syntax should require that only a single resource is involved in the evaluation, we haven’t said anything about what that filter expression might actually do during the evaluation. For example, what’s stopping a filter expression from referencing another resource, using a relationship from the starting resource to do so? Put a bit differently, should we allow a filter expression to refer to other resources and retrieve these from elsewhere (e.g., a database) in order to evaluate the expression?
答案是否定的。我们会更进一步:过滤器表达式通常根本不应该具有与其他外部系统通信的能力;相反,它应该保持密封并与外界隔绝。这意味着过滤器表达式不应该能够引用相关资源的数据,除非它已经嵌入到该资源中。例如,如果我们要根据ChatRoom资源管理员的名称进行过滤,这应该只有在administrator字段直接嵌入信息。如果该administrator字段是引用另一个资源的字符串标识符,则期望系统取消引用该字符串字段的过滤器应该失败(例如,administrator.name = "Luca")。
The answer is a hard no. And we’ll take this even further: a filter expression should generally not have the ability to communicate with other external systems at all; instead, it should remain hermetic and isolated from the outside world. This means that a filter expression should not be able to refer to a related resource’s data unless it’s already embedded in that resource. For example, if we want to filter based on the name of the administrator of a ChatRoom resource, this should only be possible if the administrator field embeds the information directly. If the administrator field is a string identifier referring to another resource, then filters expecting the system to de-reference that string field should fail (e.g., administrator.name = "Luca").
如果过滤器评估函数不是密封的并且能够与外界通信(例如,从数据库中请求新的资源数据),我们将打开相当大的蠕虫罐头,导致更多问题,类似于我们刚刚经历的问题. 例如,我们可以在多远的地方导航到相关资源?如果存储在其中一个字段中的数据实际上在检索信息时正常运行时间很短或延迟很高的系统中(例如,月球上的服务器)怎么办?最终,保持功能隔离并能够仅依赖作为参数提供的数据会降低它导致异常长或计算量大的执行的可能性。因此,保持过滤器评估密封并与其他服务隔离通常是个好主意,评估。
If filter evaluation functions were not hermetic and were able to communicate with the outside world (e.g., request new resource data from a database), we would open quite a large can of worms, leading to more questions, similar to those we just went through. For example, how far away can we navigate into related resources? What if the data stored in one of the fields is actually in a system that has poor uptime or high latency when retrieving information (e.g., the server on the moon)? Ultimately, keeping the function isolated and able to rely on only the data provided as parameters reduces the likelihood that it will lead to exceptionally long or computationally expensive executions. As a result, it’s generally a good idea to keep filter evaluation hermetic and isolated from other services, where all data needed is already present on the resource being evaluated.
什么时候过滤资源,大多数时候这些过滤条件是基于特定的字段(例如,title = "New ChatRoom!"),并且指定有问题的字段非常简单。但是,在某些情况下,指示应该使用哪个字段进行比较会稍微复杂一些。一个明显的例子是地图字段中的特定键。这些可能很棘手,主要是因为它们可能具有保留字符,这些保留字符需要任何过滤器语法来支持某种形式的转义,这将明确保留字符何时是有效的或按字面意义理解。但是还有另一个重要问题,它与我们指定值的方式无关,而与这样做的含义有关:数组中的值。
When filtering resources, most of the time these filter conditions are based on specific fields (e.g., title = "New ChatRoom!"), and specifying the field in question is pretty straightforward. However, there are some cases in which indicating which field should be used for comparison is a bit more complicated. An obvious example of this is specific keys in map fields. These can be tricky primarily because they might have reserved characters that require any filter syntax to support some form of escaping, which would make it clear when a reserved character is meant to be operative or to be taken literally. But there’s another important issue that is less about the manner in which we specify the value and more about the implications of doing so: values in an array.
当一个字段存储值数组而不是单个值并且我们打算根据这些单独的值进行过滤时,我们必然需要决定我们将如何在过滤器表达式中精确地处理这些单独的值。换句话说,我们如何根据匹配特定值的数组字段中的第一项来过滤资源?
When a field stores an array of values rather than a single value and we intend to filter based on these individual values, we’re necessarily required to decide how exactly we’ll address these individual values in a filter expression. In other words, how would we filter resources based on the first item in an array field matching a specific value?
虽然在许多语言中肯定可以表达这种类型的东西(例如,tags[0] = "new"),这很少是一个好主意,因为数组中项目的顺序是静态的隐含要求。寻址数组中的单个项目必然要求这些项目保持严格的顺序。这往往看起来很简单,但在对这些值进行操作时常常会导致很多混乱,因为更改数组值中项目的顺序会有效地导致完全不同的值,而不是稍微增加的值。例如,如果我们执行严格的排序规则,这是否意味着新值必须始终附加到数组的末尾?还是放在开头?还是根据指定的索引添加?这些问题可能看起来很简单,但它们加起来很快就会变得难以管理。
While it’s certainly possible in many languages to express this type of thing (e.g., tags[0] = "new"), this is very rarely a good idea due to the implied requirement that the order of items in the array is static. Addressing individual items in an array necessarily requires that the items are kept in a strict order. This tends to appear simple but often leads to quite a lot of confusion when operating on these values, as changing the order of the items in the array value effectively leads to a completely different value rather than a slightly augmented value. For example, if we’re enforcing strict ordering rules, does that mean that new values must always be appended to the end of the array? Or inserted at the beginning? Or added based on a specified index? These questions might seem simple, but they add up and quickly become difficult to manage.
相反,用户实际上更有可能想要测试数组中某个值(在任何位置)的存在,而不是特定索引处的值。因此,提供一种机制来检查字段是否包含测试值(例如,tags: "new"或"new" in tags)比寻址数组中特定位置的项目要方便得多。换句话说,通常最好将数组字段视为无序或具有不确定的顺序,其中项目永远不会位于任何特定位置,而是存在或不存在于数组中。当需要有序的项目列表时,依靠一个特殊字段来指示相关项目的优先级或顺序,并基于此执行过滤器(例如,item.position = 1 and item.title = "new")。
Instead, it’s actually far more likely that users will want to test for the presence of a value (in any position) in an array rather than a value at a specific index. As a result, it’s much more convenient to provide a mechanism for checking whether a field contains a test value (e.g., tags: "new" or "new" in tags) rather than addressing items in a specific position in an array. In other words, it’s often best to treat array fields as unordered or having an indeterminate order, where items are never at any specific position but instead are either present or not present in the array. And when ordered lists of items are required, rely on a special field to indicate the priority or order of the item in question and perform filters based on that (e.g., item.position = 1 and item.title = "new").
过滤器只是读取数据的论点可能很诱人,所以处理这个问题并不是什么大不了的事情——毕竟,失败的情况是你最终得到了不同的过滤结果;我们不会不小心删除 API 中的所有数据。这当然是正确的,但一个好的 API 的关键目标之一是它是一致的和可预测的。导致漏报的微妙排序要求(例如,我们期望资源与过滤器匹配,但由于项目的顺序它不匹配)可能比看起来更令人沮丧。因此,通常最好克制支持此功能的冲动,而只专注于支持测试中是否存在项目阵列。
It might be tempting to make the argument that filters are only reading data, so it’s not all that big of a deal to handle this—after all, the failure case is that you just end up with different filtered results; it’s not like we’ll accidentally erase all the data in the API. This is certainly true, but one of the key goals of a good API is that it is consistent and predictable. And subtle ordering requirements leading to false negatives (e.g., where we expect a resource to match a filter but due to the order of items it doesn’t) can be far more frustrating than they seem. As a result, it’s generally best to fight the urge to support this feature and instead focus on only supporting testing for the presence of items in arrays.
作为对于我们可能定义的任何语法,自然会出现语法规则到底有多严格的问题。例如,在某些编程语言中有相当多的灵活性(例如,有时带有可选分号字符的 Javascript),而其他语言则非常严格(例如,在许多情况下为 C++)。最终,这取决于解释器在处理某些输入时愿意进行多少猜测。那么显而易见的问题就变成了过滤器表达式应该有多严格?他们是否应该灵活并在不清楚的情况下对真正的用户意图进行大量猜测?或者是否应该要求这些表达是严格的并且不对用户可能的意思做出任何假设?一般而言,最好的策略是避免进行任何猜测,只有在意图明确无歧义时才允许灵活性。
As with any syntax that we might define, there naturally comes the question of how strict the syntax rules truly are. For example, in some programming languages there’s quite a bit of flexibility (e.g., Javascript with sometimes optional semicolon characters), while others are extraordinarily rigid (e.g., C++ in many instances). Ultimately, this comes down to how much guessing the interpreter is willing to do when processing some input. The obvious question then becomes how strict should filter expressions be? Should they be flexible and make lots of guesses about the true user intent when it’s not clear? Or should these expressions be required to be rigid and make no assumptions about what a user might have meant? In general, the best strategy is to avoid making any guesses, only allowing flexibility when the intent is clear and unambiguous. For example, when specifying field masks, we require escaping and back-tick characters for strings with special characters such as spaces or dots, but not when using the standard letters A through Z.
通常,允许过滤器表达式具有很大的灵活性是很诱人的,因为它们主要是读取数据。换句话说,由于错误解释的过滤器表达式的失败场景不会造成任何灾难性后果,为什么不允许在过程中进行更多猜测呢?虽然这确实是事实,但并不一定是正确的选择。
Often, it can be tempting to allow lots of flexibility into filter expressions because they are primarily reading data. In other words, since the failure scenario for a misinterpreted filter expression doesn’t do anything all that catastrophic, why not allow a bit more guesswork into the process? While this is certainly true, it doesn’t necessarily make it the right choice.
首先,我们有 purge 自定义方法的明显案例,它接受与特定过滤器匹配的项目过滤器(第 19 章)。在这种情况下,故障场景确实是灾难性的,可能会导致资源在不应该被删除时被删除(误报匹配),以及资源在应该被删除时被遗留下来(漏报不匹配)。这种模式虽然通常不鼓励,但受过滤器表达式语法的严格性的支配,并且由于缺乏特异性而导致的对用户意图的误解可能是非常有问题的。
First, we have the obvious case of the purge custom method, which accepts a filter of items that match a particular filter (chapter 19). In this case, the failure scenario is indeed catastrophic and may result in resources being deleted when they shouldn’t (false positive matches) as well as resources being left around when they should have been deleted (false negative mismatches). That pattern, while generally discouraged, is at the mercy of the strictness of the filter expression syntax, and a misinterpretation of the user intent due to a lack of specificity could be very problematic.
接下来,值得注意的是,在只读情况下,不匹配(误报或漏报)可能不会造成灾难性后果,但这并不会减少它们的挫败感。例如,假设我们尝试根据字段值过滤资源(例如,ChatRoom使用 过滤资源title = "New")但不小心输入了字段名称(例如,“ttile”而不是“title”)。显然,过滤器表达式无法确定我们真正想要的字段,但我们必须问自己,“该过滤器是否会导致错误?”
Next, it’s worth noting that mismatches (either false positives or false negatives) might not be catastrophic in the read-only cases, but that doesn’t make them any less frustrating. For example, imagine we attempt to filter a resource based on a field value (e.g., filtering ChatRoom resources with title = "New") but accidentally mistype the field name (e.g., “ttile” instead of “title”). Obviously the filter expression isn’t capable of figuring out which field we truly intended, but we must ask ourselves, “Should that filter result in an error?”
一方面,我们可能会像我们在字段掩码中看到的那样做,它会尝试检索这个奇怪命名的字段(“ttile”)的值并返回一个缺失值(例如,undefined在 Javascript 中)。这显然与我们正在测试的值 ( New) 不匹配,结果将过滤所有可能的资源。换句话说,此检索的值将始终解析为undefined并且永远不会等于New,因此我们实际上只是过滤了所有可能的结果。
On the one hand, we might do as we have seen with field masks, which would attempt to retrieve the value of this oddly named field (“ttile”) and come back with a missing value (e.g., undefined in Javascript). This would clearly not match with the value we’re testing (New), and the result would filter all the resources possible. In other words, the value of this retrieval will always resolve to undefined and never equal New, so we’ve effectively just filtered all possible results.
另一种选择非常简单:当字段引用无效时抛出错误。Error: Invalid field specification at position 0 ("ttile")这将确保 API 服务本身可以准确指定拼写错误的位置(例如, ),而不是经历在过滤器表达式中发现拼写错误的挫败感。尽管我们都讨厌看到这些类型的错误,但更令人沮丧的是出现最终导致下游问题的细微错误。
Another option is quite simple: throw an error when a field reference is invalid. This would ensure that rather than going through the frustration of finding a typo in a filter expression, the API service itself can specify exactly where the typo is (e.g., Error: Invalid field specification at position 0 ("ttile")). And even though we all hate seeing these types of errors, it’s far more frustrating to have subtle mistakes that end up causing downstream problems.
同样的策略适用于其他微妙的场景,例如强制类型之间的值以进行适当的比较(例如,将 1234 作为字符串转换为 1234 作为数值,反之亦然)。松散的解释可能只是针对给定条件返回不匹配的结果,而严格的解释并在不明确的情况下返回错误对于那些试图过滤资源的人来说要有用得多。例如,如果条件将数字字段与字符串值(例如,userCount = "string")进行比较,则此条件显然无法计算为true。但是,false过滤系统不应该简单地返回,而应该为明显错误的结果返回错误结果健康)状况。
This same strategy applies to other subtle scenarios such as coercing values between types for proper comparison (e.g., 1234 as a string being converted to 1234 as a numeric value or vice versa). Whereas a loose interpretation might simply return a nonmatching result for a given condition, being strict and returning an error in the case of lack of clarity is far more useful to those attempting to filter resources. For example, if a condition compares a numeric field with a string value (e.g., userCount = "string"), there’s obviously no way for this condition to evaluate to true. However, rather than simply returning false the filtering system should instead return an error result for the obviously mistaken condition.
最后,由于 API 总是有自己的一套独特要求,几乎可以肯定的是,有时用户需要“打破玻璃”来绕过这些限制,以解决基本过滤语法本身不支持的事情。例如,虽然我们通常不想在过滤器表达式中提供执行资源密集型计算的能力,但可能存在用户真正需要这种能力的独特情况。虽然我们不想为任意复杂的计算之类的事情打开闸门,但我们确实需要一种方法来允许在狭窄范围内进行这些类型的操作。这怎么行?
Finally, since APIs always have their own set of unique requirements, it’s almost guaranteed that there will be times where users will need to “break the glass” around these restrictions for things that aren’t natively supported by the basic filtering syntax. For example, while we generally don’t want to provide the ability to perform resource-intensive computation in a filter expression, there may be one unique case where a user really needs this ability. While we don’t want to open the floodgates to things like arbitrarily complex computation, we do need a way to allow these types of actions on a narrowly scoped basis. How can this work?
允许用户在特殊情况下改变或打破规则的常见解决方案是依赖表达式中的特殊函数调用。换句话说,我们不想允许运行任意代码、连接到外部数据源或通过修改本机语法来进行花哨的数据操作,因此我们可以提供辅助函数来以可控的方式执行这些操作。
The common solution to allowing users to bend or break the rules in unique circumstances is to rely on special function calls in expressions. In other words, we don’t want to allow running arbitrary code, connecting to external data sources, or fancy data manipulation by amending the native syntax, so instead we can provide helper functions to perform these actions in a well-controlled manner.
要了解这可能如何工作,请考虑对字符串执行更高级匹配的示例,例如基于前缀或后缀而不是简单的精确匹配的匹配。我们可以通过支持通配符(例如,title = "*(new)")来增加过滤器语法来处理这个问题,但这会引入一大堆新问题(例如,现在我们需要转义字符来匹配文字星号字符)。相反,我们可以使用一个特殊的endsWith函数将执行相同行为的调用(例如,endsWith(title, "(new)"))。我们甚至可以提供一个更简单的实用函数来操作字符串,例如检索字符串的子字符串的方法。这可能会导致前缀过滤看起来像substring(title, 0, 5) = "(new)".
To see how this might work, consider the example of performing more advanced matches on strings, such as matching based on prefixes or suffixes rather than simple exact matches. We could augment the filter syntax to handle this, perhaps by supporting wild cards (e.g., title = "*(new)"), but this introduces a whole pile of new problems (e.g., now we need escape characters to match on the literal asterisk character). Instead, we could use a special endsWith function call that would perform the same behavior (e.g., endsWith(title, "(new)")). We might even provide a simpler utility function for manipulating strings, such as a way to retrieve a substring of a string. This might lead to prefix filtering that looks something like substring(title, 0, 5) = "(new)".
而且这些特殊功能不应仅限于简单的字符串操作。如果一个 API 有办法对数据执行一些高级机器学习分析,我们可能会将其公开为一个特殊函数。例如,如果User资源有一个profilePhoto字段并且我们有一个用于识别图像中的事物(例如,面部、地标或常见对象)的 API,则可以提供一个函数来检查其中某些事物的存在(例如,imageContains(profilePhoto, "dog") = true)。
And these special functions shouldn’t be limited to just simple string manipulation. If an API has a way of performing some advanced machine learning analysis on data, we might expose that as a special function. For example, if User resources have a profilePhoto field and we have an API for recognizing things in images (e.g., faces, landmarks, or common objects), it is possible to provide a function that will allow checking for the presence of some of these things (e.g., imageContains(profilePhoto, "dog") = true).
在这种情况下要考虑的重点不是这些函数可能做什么,而是它们是打破基本语法规则的一种方式。这样,过滤资源的语法仍然简单明了,而那些使用 API 的人仍然能够在 API 决定它对用例至关重要时执行更高级的过滤在题。
The point to consider in this case is not what these functions might be capable of doing, but that they are a way to break basic syntax rules. This way, the syntax for filtering resources remains simple and straightforward, while those using the API still have the ability to perform more advanced filtering if the API decides it’s critically necessary for the use case in question.
在在这种情况下,过滤资源的 API 定义非常简单,只涉及更新标准列表请求以包含过滤字段。但是,使该字段采用字符串类型的决定是重要的一。
In this case, the API definition for filtering resources is quite simple and involves only updating the standard list request to include a filter field. However, the decision to make this field take a string type is an important one.
Listing 22.8 Final API definition
abstract class ChatRoomApi { @get("/chatRooms") ListChatRooms(req: ListChatRoomsRequest): ListChatRoomsResponse; } interface ListChatRoomsRequest { filter: string; maxPageSize: number; pageToken: string; } interface ListChatRoomsResponse { results: ChatRoom[]; nextPageToken: string; }
这最明显的权衡是在支持过滤和不支持过滤之间。虽然在标准列表请求中提供过滤资源的能力通常需要更多资源,但为用户提供的好处几乎肯定会超过任何额外的短期成本。此外,如果用户被迫通过获取所有资源来滚动他们自己的过滤,那么这些用户不仅在浪费他们自己的时间,他们还在浪费 API 的计算资源来检索和传输相当多的信息,这些信息可能已经被提前过滤掉了。因此,虽然在某些情况下过滤并不是绝对必要的,但大多数资源的标准列表方法应该支持该功能,特别是在获取的资源数量可能增长到足够大的情况下(例如,
The most obvious trade-off is between supporting filtering and not. While providing the ability to filter resources in a standard list request is often more resource intensive, the benefits provided to users almost certainly outweigh any additional short-term cost. Further, if users are forced to roll their own filtering by fetching all resources, these users aren’t just wasting their own time, they’re wasting the computing resources of the API in order to retrieve and transport quite a lot of information that could’ve been filtered out ahead of time. As a result, while there are cases where filtering isn’t critically necessary, the standard list method for most resources should support the functionality, particularly if the number of resources being fetched could grow to be sufficiently large (e.g., measured in the several hundreds into the thousands).
此外,如第 22.3.1 节所述,可能最大的权衡是在结构化过滤器和非结构化过滤器字符串之间进行选择。虽然它们在技术上都代表同一事物,但使用字符串意味着随着时间的推移对结构格式的更改更容易管理,而无需传达任何新的技术 API 规范(例如,开放 API 规范文档)。取而代之的是,使用字符串可以更新文档并支持新格式,而无需将太多其他信息传回给用户。
Additionally, perhaps the biggest trade-off, as discussed in section 22.3.1, is the choice between a structured filter and an unstructured filter string. While they both are technically representative of the same thing, using a string means that changes to the format of the structure over time are easier to manage without communicating any new technical API specifications (e.g., open API specification documents). Instead, with a string the documentation can be updated and the new format supported without much else needed to be communicated back to users.
What is the primary drawback of using a structured interface for a filter condition?
Why is it a bad idea to allow filtering based on the position of a value in an array field?
How do you deal with map keys that may or may not exist? Should you be strict and throw errors when comparing against keys that don’t exist? Or treat them as missing?
Imagine a user wants to filter resources based on a field’s suffix (e.g., find all people with names ending in “man”). How might this be represented in a filter string?
Providing filtering on standard list methods means that users aren’t required to fetch all data in order to find only those they’re interested in.
Filter specifications should generally be strings adhering to a specific syntax (akin to a SQL query) rather than a structured interface to convey the same intent.
Filter strings should only require a single resource as the context for evaluation to avoid unbounded growth in execution time.
Filters should not provide a way to compare items on a resource based on their position or index in a repeated field (e.g., an array).
Errors discovered during a filter evaluation should be surfaced immediately rather than hidden or ignored.
If basic comparisons are insufficient for users’ intentions, filters should provide a set of documented functions that can be interpreted and executed while filtering.
在此模式中,我们将探讨如何通过自定义导入和导出方法安全灵活地将资源移入和移出 API。更重要的是,这种模式依赖于 API 直接与外部存储系统通信,而不是通过中间客户端。我们将通过定义几个松散耦合的配置结构来完成所有这些工作,以涵盖这些资源在 API 和底层存储系统之间的旅程的各个方面,从而最大限度地提高灵活性和可重用性。
In this pattern, we’ll explore how to safely and flexibly move resources in and out of an API via custom import and export methods. More importantly, this pattern relies on the API communicating directly with the external storage system rather than through an intermediary client. We’ll make all of this work by defining several loosely coupled configuration structures to cover the various aspects of these resources on their journey between the API and the underlying storage system, maximizing both flexibility and reusability.
在任何管理用户提供的数据的 API 中(几乎每个 API 都是如此),我们已经定义了几种获取系统资源和从系统获取资源的方法。其中一些在单个资源上工作(例如,全套标准方法),而其他一些在资源组上运行(例如,批处理方法组),但它们都有一个重要的共同点:数据总是从 API 服务直接传输回客户端。
In any API that manages user-supplied data (which is almost every API), we’ve already defined several ways to get resources in and out of the system. Some of these work on individual resources (e.g., the full suite of standard methods), while others operate on groups of them (e.g., the group of batch methods), but all of them have one important thing in common: the data is always transferred from the API service back to the client directly.
让我们考虑一下我们有一些序列化数据要加载到 API 中的情况,例如一堆Message资源ChatRoom在资源内创建. 一种方法是编写一个应用程序服务器,从存储系统中检索数据,将序列化字节解析为实际资源,然后使用传统方法在 API 中创建新资源。虽然这可能会带来不便,但肯定会完成工作。
Let’s consider the case where we have some serialized data we want to load into an API, such as a bunch of Message resources to be created inside a ChatRoom resource. One way to do this is to write an application server that retrieves the data from the storage system, parses the serialized bytes into actual resources, and then uses traditional methods to create the new resources in the API. While this might be inconvenient, it will certainly get the job done.
该计划的一个关键方面可能会成为问题:数据需要采用的路线。在这种情况下,数据必须走很长的路,从存储系统到执行加载的应用程序代码,最后到 API,如图 23.1 所示。
There’s one key aspect of this plan that might turn out to be a problem: the route the data is required to take. In this case, the data must take quite a long route, going from the storage system to the application code that will do the loading, and then finally to the API, as shown in figure 23.1.
Figure 23.1 Importing data requires an intermediate data loader application.
如果数据碰巧与应用程序服务器位于同一台机器上(甚至附近),这并不是一个可怕的提议。但如果基础设施安排不是那么友好呢?例如,如果数据和 API 彼此靠近,但不允许应用程序服务器靠近怎么办?这意味着加载数据的自定义函数会将整个数据集一直传输到应用程序服务器,然后一直传输回 API 服务器。这有点像每次你需要一杯牛奶时就去商店,而不是买一盒牛奶放在冰箱里:两者都消耗能量,但一个效率更高。
If the data happens to be on the same machine as the application server (or even nearby), this is not a horrible proposition. But what if the infrastructure arrangement isn’t so friendly? For instance, what if the data and API live near one another, but the application server is not permitted to live nearby? This means that the custom function to load the data will be transporting the entire data set all the way to the application server and then all the way back to the API server. It’s sort of like going to the store every time you need a glass of milk rather than buying a carton and keeping it in your fridge: both consume energy, but one is far more efficient.
这个问题也适用于在另一个方向上流动的数据。如果我们不想将新资源加载到系统中,而是想从系统中取出现有资源,那么使用自定义代码来实现这一点需要数据流经这个中间应用程序服务器。随着这两种情况下数据量的增长(如今的数据往往如此),这个问题只会变得更糟。
This problem carries over for data flowing in the other direction as well. If, instead of loading new resources into the system, we want to take existing resources out of the system, using custom code to do so requires the data flow through this intermediary application server. And as the amount of data grows for either scenario (as data tends to do nowadays), this problem only gets worse.
因此,有一个非常有力的理由让数据直接在 API 和远程存储系统之间流动。它不仅可以最大限度地减少用户需要编写的自定义代码的数量,还可以减少将数据从一个地方传输到另一个地方所需的带宽浪费和用户管理的计算资源。在下一节中,我们将概述这个低效流程的解决方案。
Because of this, there’s a pretty strong case to be made for having data flow directly between the API and a remote storage system. Not only would it minimize the amount of custom code users would need to write, it would also reduce wasted bandwidth and user-managed compute resources necessary to get data from one place to another. In the next section, we’ll outline a solution to this inefficient process.
在在这种模式下,我们将依赖两种特殊的自定义方法将资源移入和移出 API:导入和导出,如图 23.2 所示。由于总体目标是让 API 直接与外部存储系统交互,因此这些方法中的每一个都将承担在 API 服务和存储系统之间传输所有数据的责任。此外,由于任何外部存储系统不太可能以与任何 API 服务完全相同的方式存储数据,因此这些方法还需要承担将 API 资源转换为存储系统理解的原始字节(反之亦然)的责任。
In this pattern, we’ll rely on two special custom methods for moving resources in and out of an API: import and export, outlined in figure 23.2. Since the overall goal is to allow the API to interact directly with an external storage system, each of these methods will take on the responsibility of transporting all data between the API service and the storage system. Further, since it’s unlikely that any external storage system stores data in the exact same way as any API service, these methods will also need to take on the responsibility of converting API resources into raw bytes (and vice versa) that the storage system understands.
图 23.2 一个import操作将数据检索和处理结合到 API 服务中。
Figure 23.2 An import operation combines data retrieval and processing into the API service.
在这两个配置方面出现的问题千差万别。那里有大量的存储系统,因此我们为这些自定义方法构建请求消息的方式很重要,这样我们就可以在 API 用户需要时轻松引入新方法。此外,还有大量不同的序列化格式可供使用,可用于将 API 资源转换为原始字节,因此请求结构必须足够灵活以支持当今存在的任何格式,但也可以扩展以支持任何格式。将来可能出现的新的。
The problems arise in sheer variety on both of these configuration aspects. There are an enormous number of storage systems out there, so it’s critical that we structure the request messages for these custom methods in such a way that we can easily introduce new ones as the users of the API demand them. Further, there is a similarly vast collection of different serialization formats available that can be used to transform API resources into raw bytes, so it’s important that the request structure be flexible enough to support any of those in existence today, but also grow to support any new ones that might appear in the future.
如果这还不够复杂,在数据被序列化为一堆字节之后,可能需要执行进一步的转换。例如,数据在存储到磁盘之前被压缩或加密的情况并不少见。由于这些自定义方法必须直接与外部存储系统通信,因此它们还负责允许 API 服务的用户配置这些附加参数。
And if this wasn’t complicated enough, after data has been serialized into a bunch of bytes, there may be a need to perform further transformations. For example, it’s not unusual for data to be compressed or encrypted before being stored on a disk. And since these custom methods must communicate directly with the external storage system, they’re also responsible for allowing users of the API service to configure these additional parameters.
为了完成所有这些工作,我们将依赖一个总体配置界面作为导入和导出自定义方法的主要输入。这些配置接口(InputConfig用于进口和OutputConfig用于导出)负责存储所有相关信息,用于在资源和字节之间转换数据以及将这些字节传输到外部存储系统或从外部存储系统传输这些字节。
To make all of this work, we’ll rely on an overarching configuration interface as the primary input to the import and export custom methods. These configuration interfaces (InputConfig for import and OutputConfig for export) are responsible for storing all of the relevant information for both transforming data between resources and bytes as well as transporting those bytes to and from the external storage system.
由于存储系统的数量巨大,变化很大,并且不断变化,我们将依赖允许我们将数据传输配置与数据转换配置分开和独立的结构。换句话说,我们会将如何访问外部存储的配置与如何序列化 API 资源或反序列化原始字节的配置分开。为此,我们将使用泛型DataSource和DataDestination接口,这样每个扩展接口都将包含将字节传输到预期源或目标或从预期源或目标传输字节所需的所有配置。
Since the number of storage systems is immense, varies widely, and is constantly changing, we’ll rely on structures that allow us to keep the configuration for data transportation separate and independent from that of data transformation. In other words, we’ll separate configuration for how to access external storage separate from the configuration for how to serialize API resources or deserialize raw bytes. For this, we’ll use generic DataSource and DataDestination interfaces, such that each extending interface will include all the configuration necessary for transporting bytes to or from the intended source or destination.
一旦我们解决了结构问题并决定如何安排所有这些配置细节,我们就剩下一些复杂的问题需要回答有关每个自定义方法的行为。例如,我们如何处理失败并决定重试请求是否安全?数据的底层一致性如何?我们在导出数据的时候,是对数据进行完整的快照并导出,还是允许涂片?同样,当我们导入数据时,新资源如何与现有资源交互?我们如何处理标识符冲突?这些应该在导出数据之前被剥离吗?
Once we’ve addressed the structural concerns and decide on how to arrange all of these configuration details, we’re left with some complicated questions to answer about the behavior of each of these custom methods. For example, how do we handle failures and decide on whether it’s safe to retry requests? What about the underlying consistency of the data? When we export data, do we take a full snapshot of the data and export that, or is it allowed to be a smear? Similarly, when we import data, how do the new resources interact with the existing ones? And how do we handle identifier collisions? Should these be stripped before exporting data?
在下一节中,我们将更深入地探讨这些行为问题以及所有结构性问题细节。
In the next section, we’ll explore these behavioral questions as well as all of the structural concerns in quite a bit more detail.
现在我们已经概述了高级问题,是时候开始深入研究细节了。让我们从查看自定义导入和导出方法本身开始,然后我们将探讨它们各自的请求接口是如何构建的。
Now that we’ve outlined the high-level problems, it’s time to start digging into the details. Let’s start by looking at the custom import and export methods themselves, and then we’ll explore how their respective request interfaces are structured.
自从我们的目标是直接向外部存储系统导入和导出数据,我们需要做的第一件事是定义自定义导入和导出方法,以管理所有这些操作的复杂性。尽管我们一直在笼统地讨论这些方法,但重要的是要注意这些自定义方法,就像我们在第 9 章中看到的那样,将被锚定到 API 中的特定资源。换句话说,每个自定义导入或导出方法将负责单一资源类型(例如,ImportMessages要么ExportMessages).
Since our goal is to import and export data directly to and from an external storage system, the first thing we need to do is define the custom import and export methods that will manage all of the complexity of these operations. Even though we’ve been talking about these methods generically, it’s important to note that these custom methods, like those we saw in chapter 9, will be anchored to a specific resource in an API. In other words, each custom import or export method will be responsible for a single resource type (e.g., ImportMessages or ExportMessages).
关于这些方法的下一个(可能很明显)值得强调的属性是返回类型。正如我们在第 10 章中了解到的,当 API 方法不是即时的或可能需要一段时间时,最好让这些返回某种承诺以完成实际工作。虽然这些自定义方法可能会相对快速地完成,但它们冒着花费相当多时间的风险,因此绝对符合返回 LRO 和最终结果而不是同步返回结果的准则。
The next (potentially obvious) property about these methods worth highlighting is the return type. As we learned in chapter 10, when API methods are not immediate or might take a while, it’s best to make these return a promise of sorts to complete the actual work. While it’s possible that these custom methods could complete relatively quickly, they run the risk of taking quite a bit of time and, as a result, definitely fit the guidelines for returning an LRO and an eventual result rather than a result synchronously.
通过将这两部分放在一起并遵循第 9 章的其他指导,我们可以很容易地得出两个导入Message资源的示例方法到给定的ChatRoom资源.
By putting these two pieces together and following the other guidance from chapter 9, we can pretty easily come up with two example methods to import Message resources into a given ChatRoom resource.
Listing 23.1 Example of custom import and export methods
abstract class ChatRoomApi { @post("/{parent=chatRooms/*}/messages:export") ❶ ExportMessages(req: ExportMessagesRequest): Operation<ExportMessagesResponse, ExportMessagesMetadata>; @post("/{parent=chatRooms/*}/messages:import") ❶ ImportMessages(req: ImportMessagesRequest): Operation<ImportMessagesResponse, ImportMessagesMetadata>; }
❶ We’re importing many resources into a collection belonging to a parent resource.
还需要注意的是,这两个自定义方法侧重于将多个资源导入到集合中,但这不是唯一可用的选项。我们可能还想导入不由一组 API 资源表示的数据,而只是许多数据点,这些数据点在导入后可以单独寻址。例如,如果我们的聊天 API 包括一个用奇特的机器学习算法训练的虚拟助手,我们可能有办法将训练数据点导入到单个VirtualAssistant资源中,导致自定义方法。
It’s also important to note that these two custom methods are focused on importing multiple resources into a collection, but that’s not the only option available. We might also want to import data that isn’t represented by a set of API resources and is, instead, just many data points which, after being imported, are individually addressable. For example, if our chat API included a virtual assistant trained with a fancy machine learning algorithm, we might have a way to import training data points into a single VirtualAssistant resource, resulting in the custom method.
Listing 23.2 Example of importing non-addressable data points into a resource
abstract class ChatRoomApi { @post("/{id=virtualAssistants/*}:importTrainingData") ❶ ImportTrainingData(req: ImportTrainingDataRequest): Operation<ImportTrainingDataResponse, ImportTrainingDataMetadata>; }
❶我们可以将不可寻址的数据导入到特定资源中,而不是导入到集合中。
❶ Rather than importing into a collection, we might import non-addressable data into a specific resource.
正如我们所见,这些都是看起来很普通的自定义方法,它们返回最终将解析为标准响应的 LRO。正如我们将在下一节中学习的那样,困难的部分是我们应该如何构建请求方法。
As we can see, these are pretty ordinary-looking custom methods that return LROs that will eventually resolve to standard responses. The tough part, as we’ll learn in the next section, is how we should structure the request methods.
自从连接到外部存储系统是我们导入和导出数据设计的一个关键组成部分,我们需要一个地方来为每个存储系统放置所有可能的配置选项才有意义。通常,将所有不同的配置选项简单地放在一起可能很诱人,这样您就有了一个能够与所有存储系统对话的配置模式。然后,当我们需要添加对新的支持时,我们可能只需要向这个配置界面添加一些新字段,然后就可以收工了。
Since connecting to external storage systems is a key component of our design to import and export data, it only makes sense that we’ll need a place to put all the possible configuration options for each of these storage systems. Often, it can be tempting to simply throw in all the various configuration options together, such that you have a single configuration schema that’s capable of talking to all the storage systems out there. Then, when we need to add support for a new one, we might just add some new fields to this configuration interface and call it a day.
除了相当混乱之外,这种设计还有问题,因为它会导致相当多的混乱。首先,当我们有一个单一的界面,其中有大量的选项紧挨着彼此时,决定在什么情况下需要哪些选项会变得很有挑战性。由于缺少数据源或目标的配置,这会导致不必要的来回 API 服务器拒绝各种导入或导出请求。
Besides being quite messy, this design is problematic because it can lead to quite a bit of confusion. First, when we have a single interface with a large number of options all right next to each other, it can become challenging to decide which ones are required under which circumstances. This leads to unnecessary back and forth with the API server rejecting the various import or export requests because of missing configurations for the data source or destination.
其次,当我们继续向平面结构添加越来越多的配置选项时,我们的下一个诱惑是重用界面上的现有字段,以便它们可能适用于多个不同的存储系统。换句话说,而不是有一个sambaPassword领域连同一个s3secretKey领域,我们可能只有一个password字段并尝试将其用于 Amazon Web Services 发布的 S3 密钥以及 Samba 网络共享上特定用户的密码。起初这看起来是个好主意,但当一个字段用于多个目的时会导致更多的混乱,因为它恰好有一个听起来相似的名字。
Second, when we continue adding more and more configuration options to a flat structure, our next temptation is to reuse existing fields on the interface such that they might apply to multiple different storage systems. In other words, rather than having a sambaPassword field along with a s3secretKey field, we might just have one password field and attempt to use it for both the S3 secret key issued by Amazon Web Services as well as the password for a specific user on a Samba network share. This looks like a good idea at first but leads to even more confusion when a single field is used for more than one purpose, just because it happens to have a similar-sounding name.
一个更直接的解决方案是拥有一个单一的多态字段,然后为每个支持的不同存储系统提供特定的接口。换句话说,我们可能有一个S3DataSource用于从亚马逊的简单存储服务加载数据以及SambaDataSource,它能够与 Samba 网络共享进行交互。
A more straightforward solution is to have a single polymorphic field that then has specific interfaces for each different storage system supported. In other words, we might have a S3DataSource for loading data from Amazon’s Simple Storage Service as well as a SambaDataSource, which is capable of interacting with Samba network shares.
Listing 23.3 Example using S3 as a data source
interface DataSource { type: string; ❶ } interface S3DataSource extends DataSource { ❷ type: 's3'; ❶ bucket: string; glob: string; ❸ }
❶ The type is a static string for the final interfaces with the identifier of the storage system.
❷ The specific DataSource interfaces extend the base.
❸在这里,我们存储匹配对象的 glob 表达式(例如,archive-*.gzip)以将其视为数据源。
❸ Here we store a glob expression (e.g., archive-*.gzip) of matching objects to treat as a data source.
同样的模式适用于作为数据从 API 流出的目的地的存储服务,从而为 API 带来模块化、可重用的概念,以便在导入或导出各种资源时可以重用与外部存储系统的连接。但这引出了一个有趣的问题:如果这些接口只是关于如何连接到存储系统,为什么我们需要单独的接口来读取数据而不是写入数据?
This same pattern applies for storage services as destinations for data flowing out of the API, leading to a modular, reusable concept for the API, such that connections to external storage systems can be reused when importing or exporting a variety of resources. But this leads to an interesting question: if these interfaces are just about how to connect to a storage system, why do we need separate interfaces for data being read than for data being written?
虽然这两个概念密切相关(毕竟,它们都是关于与外部存储系统交互和传输数据的),但它们并不相同。例如,当我们使用 Amazon 的 S3 作为数据源时,我们希望应用一种模式(在本例中为 glob 表达式 [ https://en.wikipedia.org/wiki/Glob ])来选择哪些 S3 对象(就像磁盘上的文件一样)进行检索。另一方面,当导出数据时,我们更可能有一个输出文件或可能有几个不同的文件,但都放在同一个目录中(在 S3 中,通过使用文件名前缀表示,如exports/2020/)。
While these two concepts are closely related (after all, they’re both about interacting with external storage systems and transporting data), they’re not identical. For example, when we are using Amazon’s S3 as a source of data, we want to apply a pattern (in this case, a glob expression [https://en.wikipedia.org/wiki/Glob]) to select which S3 objects (just like files on a disk) to retrieve. On the other hand, when exporting data, we’re more likely to have either a single output file or potentially several different files, but all placed inside the same directory (which in S3, is expressed by using a prefix to the file name, such as exports/2020/).
Listing 23.4 Example using S3 as a data destination
interface DataDestination { type: string; } interface S3DataDestination extends DataDestination { type: 's3'; bucket: string; prefix: string; ❶ }
❶在这种情况下,我们有一个前缀来确定如何命名写入的对象,而不是 glob 表达式。
❶ In this case, rather than a glob expression, we’d have a prefix to determine how written objects would be named.
换句话说,虽然这两个概念非常相似,但它们并不相同,也不代表同一事物。可能有很多重叠字段,但这是设计使然,允许两个接口独立更改。这种松散耦合是一种即使面对巨大的波动也能保持灵活性的设计。
In other words, while these two concepts are very similar, they are not identical and don’t represent the same thing. There may be many overlapping fields, but this is by design to allow the two interfaces to change independently. And this type of loose coupling is the type of design that remains flexible even in the face of immense volatility.
现在我们已经有了一种配置 API 应该从外部存储系统检索字节(或将字节发送到)的方式的方法,我们可以进行下一部分:弄清楚如何在 API 资源和那些资源之间进行转换字节。
Now that we have a way to configure the way an API should retrieve bytes from (or send bytes to) an external storage system, we can get to the next piece: figuring out how to convert between API resources and those bytes.
它是有趣的是,由于构建 Web API 的本质,我们已经有了一种将底层 API 资源转换为序列化字节块的机制。通常这依赖于像 JSON 这样的格式;然而,每个 API 都需要一种通过 Internet 发送数据的方法,因此必须有一种为用户序列化数据的方法。虽然这种预先存在的机制会派上用场,但序列化大量资源以供导出(或反序列化以供导入)并不是一回事。例如,我们可能使用 JSON 来序列化单个资源,但在导出数据时,我们可能依赖修改后的格式(例如,换行分隔的 JSON 而不是 JSON 资源数组)或完全不同的格式(例如,CSV 或 TSV带有标题行)。我们不会详细介绍资源序列化的所有细节,而是一个序列化某些资源的示例Message资源到 CSV 的格式如清单 23.5 所示。(如果您想知道为什么缺少标识符,请查看第 23.3.5 节。)
It’s interesting to note that by the very nature of building a web API, we already have a mechanism to turn the underlying API resources into a serialized chunk of bytes. Typically this relies on a format like JSON; however, every API needs a way to send data over the internet, and as a result must have a way of serializing data for users. While this pre-existing mechanism will come in handy, serializing lots of resources for export (or deserializing for import) is not quite the same thing. For example, we might use JSON to serialize a single resource, but when exporting data we might rely on a modified format (e.g., new line separated JSON rather than a JSON array of resources) or a different format entirely (e.g., CSV or TSV with a header row). We won’t go into all of the details of resource serialization, but an example of serializing some Message resources to CSV is shown in listing 23.5. (If you’re wondering why the identifier is missing, look at section 23.3.5.)
Listing 23.5 Sample CSV serialized Message resources
sender, type, content ❶ "users/1", "text", "hi" ❷ "users/2", "text", "hi back"
❶ In data exported as CSV, the first row is typically a header row defining the columns.
❷ Each row stores the fields to be imported.
此外,即使在我们序列化数据之后,我们也可能希望在存储数据以供导出之前执行进一步的转换(在另一个方向上也是如此)。这可能像使用一些用户提供的密钥压缩或加密数据一样简单,也可能比这更复杂一些,例如将数据分成几个不同的文件以存储在外部存储服务上。
Further, even after we’ve serialized the data, we might want to perform further transformations before storing it for export (and similarly in the other direction). This might be something as simple as compressing or encrypting the data with some user-provided secret key, or it might be a bit more complicated than that, such as chopping the data up into several different files to be stored on the external storage service.
不管我们想对数据做什么,我们当然需要一个地方来放置所有这些配置,为此我们将使用一个InputConfig接口(或OutputConfig接口用于导出数据)。就像自定义导入和导出方法一样,这些接口将以每个资源为基础,从而导致像这样的接口MessageInputConfig.
Regardless of what we might want to do with the data, we certainly need a place to put all of this configuration, and for that we’ll use an InputConfig interface (or an OutputConfig interface for exporting data). And just like the custom import and export methods, these interfaces will be on a per-resource basis, leading to interfaces like MessageInputConfig.
Listing 23.6 Example input and output configuration for Message resources
interface MessageInputConfig { ❶ // The content type of the input. // Choices: "json", "csv", undefined (auto-detected) contentType?: string; // Choices: "zip", "bz2", undefined (not compressed) compressionFormat?: string; } interface MessageOutputConfig { ❶ // The content type for serialization. // Choices: "json", "csv", undefined for default. contentType?: string; // Use ${number} for a zero-padded file ID number. // Content type will be appended with file extension (e.g., ".json"). // Default: "messages-part-${number}" filenameTemplate?: string; maxFileSizeMb?: number; // Choices: "zip", "bz2", undefined (not compressed) compressionFormat?: string; }
❶ These two are quite similar but not identical, as they have different responsibilities.
希望这一切看起来都很无聊且无伤大雅。真正的价值不在于导入和导出数据的特定配置参数,而是在于分离检索原始数据与处理该数据的关注点。这种松耦合确保我们可以重用数据源和目标,以便跨 API 导入和导出各种资源类型。
Hopefully, this should all look quite boring and innocuous. The real value isn’t in the specific configuration parameters for importing and exporting data, but instead in the separation of concerns for retrieving raw data versus processing that data. This loose coupling ensures that we can reuse data sources and destinations for importing and exporting a variety of resource types across the API.
现在我们已经介绍了结构方面,让我们换个话题,开始深入研究这种模式的行为细微差别,从我们应该如何解决底层资源一致性开始应用程序接口。
Now that we’ve covered the structural aspects, let’s switch gears and start digging into the behavioral nuances of this pattern, starting with how we should address underlying resource consistency in the API.
什么时候谈到导出数据,我们显然需要阅读用于导出的整个资源集合。尽管我们希望我们可以立即读取任意数量的资源,但通常只有在资源数量足够少的情况下才会出现这种情况。对于可能更大的集合,读取所有数据显然会花费一些非零时间。这本身不一定是个问题,但不能保证这个导出操作将独占访问系统。这就引出了一个重要的问题:我们应该如何处理在导出操作过程中可能发生变化的资源?
When it comes to exporting data, we’re obviously required to read the entire collection of resources that are intended for export. And as much as we wish we could read any number of resources instantaneously, that’s typically only the case with sufficiently small numbers of resources. For collections that might be larger, reading all of the data will clearly take some nonzero amount of time. This on its own is not necessarily a problem, but there’s no guarantee that this export operation will have exclusive access to the system. This leads to an important question: what should we do about resources that might be changing during the course of an export operation?
有两个简单的选项可以解决这个问题。一种是依赖底层资源存储系统提供的快照或事务能力。例如,我们可能会在特定时间点读取所有资源,以确保我们对正在导出的资源集合有一致的了解。如果我们这样做,在导出操作运行时同时进行的任何更改都不会反映在导出的数据中。
There are two simple options to address this issue. One is to rely on the snapshot or transactional capabilities provided by the underlying resource storage system. For example, we might read all the resources at a specific point in time, ensuring that we have a consistent picture of the resource collection we’re exporting. If we do this, any changes made concurrently while the export operation is running will not be reflected in the data being exported.
不幸的是,并非所有存储系统都支持这种类型的快照功能。更糟糕的是,能够处理超大数据集的系统往往不太可能这样做。我们可以做什么?
Unfortunately, not all storage systems support this type of snapshotting functionality. And even worse, the systems capable of handling exceptionally large data sets tend to be less likely to do so. What can we do?
另一种选择对于导出操作来说是完全可以接受的,那就是承认我们可以用现有的存储系统做的最好的事情就是提供对导出数据的污点。这意味着我们导出的资源可能不是任何单个时间点的数据的精确表示,而是在导出操作运行期间尽最大努力尝试在 API 中收集资源。虽然这当然不理想,但在许多情况下,这将是唯一可用的选择,因此是我们能做的最好的选择。
The other option, which is perfectly acceptable for an export operation, is to acknowledge that the best we can do with the storage system we have is to provide a smear of the data being exported. This means that the resources we export might not be an exact representation of the data at any single point in time but a best effort attempt of the collection of resources in the API for the duration that the export operation is running. While this is certainly not ideal, in many cases it will be the only option available and thus is the best we can do.
如果这种数据污点的想法有点不清楚,让我们考虑一个例子。在表 23.1 中,前两列显示导出操作的操作以及用户同时操作系统中的数据。最后两列显示数据的当前状态(存在哪些资源)以及已导出的数据。
If this idea of a smear of data is a bit unclear, let’s consider an example. In table 23.1, the first two columns show the actions of an export operation as well as a user concurrently manipulating the data in the system. The final two columns show both the current state of the data (which resources exist) as well as the data that’s been exported.
由于双方(用户和导出操作)动作的独特交错,我们实际上最终得到了一组看起来有点奇怪的资源。这组特定的资源(A、B 和 D)从未一起存在过(检查存储的数据列以查看此资源组合从未同时存在)。这种类型的涂抹当然不常见,但当我们没有能力对数据的一致快照进行操作时,它是一种必要的罪恶。
Due to the unique interleaving of the actions of both parties (the user and the export operation), we actually end up with a set of resources that looks a bit strange. This specific group of resources (A, B, and D) never existed together (check the Stored data column to see that this combination of resources was never present at the same time). This type of smear is certainly unusual, but is a necessary evil when we don’t have the ability to operate on a consistent snapshot of the data.
Table 23.1 Export scenario with a smear of data
如果 API 的存储系统不支持读取数据的一致快照,但这种不一致的导出数据是完全不能接受的,另一种要考虑的选择是在导出过程中禁用修改 API 中的资源。虽然这肯定会带来不便(并且在导出期间涉及系统范围的操作停机),但它有时是唯一可用的安全选项(事实上,谷歌云数据存储如何建议消费者导出存储在该存储系统中的数据的一致视图).
If the storage system of the API doesn’t support consistent snapshots for reading data but this inconsistent export data is completely unacceptable, one other option to consider is to disable modifying resources in the API while an export is in process. While this is certainly an inconvenience (and involves system-wide operational downtime during an export), it’s sometimes the only safe option available (and is, in fact, how Google Cloud Datastore recommends consumers export a consistent view of data stored in that storage system).
如果本节有一个关键要点,那就是导出数据与备份数据不同这一关键思想。备份意味着数据快照,包括各种唯一标识信息,而数据导出实际上是通过传统方式检索数据,将数据直接路由到外部存储服务。在下一节中,我们将看看我们应该如何处理正在导出的资源中的唯一标识信息,因为它比它复杂一点似乎。
If there’s one key takeaway from this section it should be the critical idea that exporting data is not the same as backing up data. Whereas backups imply a snapshot of the data, including all sorts of uniquely identifying information, an export of the data is really about retrieving data through traditional means, routing that data directly to an external storage service. In the next section, we’ll look at what exactly we should do with unique identifying information in resources being exported, because it’s a bit more complicated than it seems.
所以到目前为止,我们一直假设在序列化给定资源时,它应该包括该资源的所有字段,包括标识符。尽管这看起来像是一个很小的细节,但事实证明,这个单一领域会引导我们提出很多有趣的问题。例如,在将资源导入集合时,如果我们在源数据中遇到资源的 ID 已在 API 中使用,我们应该怎么办?
So far we’ve been operating under the assumption that when we serialize a given resource, it should include all the fields for that resource, including the identifier. Even though this might seem like a tiny detail, it turns out that this single field leads us to quite a few interesting questions. For example, when importing resources into a collection, what should we do if we come across a resource in the source data that has an ID already in use in the API?
另一个有趣的情况是,我们导出具有特定标识符的资源,然后尝试将这些相同的资源导入回 API,但导入到不同的父资源中。换句话说,如果我们导出MessageID 设置为 的资源chatRooms/1234/messages/2并且我们想将该资源导入到ChatRoom#5678 中怎么办?它应该被创建为chatRooms/5678/messages/2或被赋予一个完全不同的标识符吗?
Another interesting case comes up when we export resources with specific identifiers but then attempt to import those same resources back into the API but into a different parent resource. In other words, what if we export a Message resource with an ID set to chatRooms/1234/messages/2 and we want to import that resource into ChatRoom #5678? Should it be created as chatRooms/5678/messages/2 or be given an entirely different identifier?
最后,我们不得不怀疑 API 不允许在资源中使用用户指定的标识符的情况。我们应该如何处理我们尝试导入的资源的标识符?别管他们?正如我们将在 23.3.7 节中看到的,当我们开始探索当我们需要重试先前失败的导入时会发生什么时,这变得非常重要。
Finally, we have to wonder about the case where the API doesn’t permit user-specified identifiers in resources. What should we do with the identifiers of the resources we’re trying to import? Ignore them? As we’ll see in section 23.3.7, this becomes quite important when we start exploring what happens when we need to retry a previously failed import.
幸运的是,我们可以回到我们最初对导入和导出的定义来回答这个问题。自定义导入和导出方法旨在提供一种弥合 API 和外部存储系统之间差距的方法,在将数据从一个地方洗牌到另一个地方时,减少作为中间人的应用程序代码。这意味着功能应该相当于从外部存储位置获取数据,并使用批量创建方法根据提供的数据创建一堆新资源。坦率地说,超出这个简单(和幼稚)功能的任何东西都超出了范围。
Luckily, we can fall back on our original definition of importing and exporting to answer this question. The custom import and export methods are intended to provide a way to bridge the gap between the API and an external storage system, cutting out the application code as the middleman when shuffling data from one place to another. This means that the functionality should be equivalent to taking data from an external storage location and using the batch create method to create a bunch of new resources based on the data provided. Anything beyond this simple (and naive) functionality is, quite frankly, out of scope.
这个定义的结果非常强大:如果不支持用户指定的标识符(通常应该是这种情况,正如我们在第 6 章中学到的),那么自定义导入方法应该忽略提供的任何标识符(例如,id字段) 在 API 中创建新资源时。也就是说,在我们稍后需要确定数据来源的场景中,在导出数据时保留标识符是有意义的。
The result of this definition is pretty powerful: if user-specified identifiers are not supported (which should generally be the case, as we learned in chapter 6), then the custom import method should ignore any identifiers provided (e.g., the id field) when creating new resources in the API. That said, it makes sense to keep the identifiers when exporting data in the scenario where we need to determine the source of the data later on.
但这是否意味着如果我们两次导入相同的数据,我们可能会创建大量重复的资源?当然,但这是设计使然。
But doesn’t this mean that we might be able to create lots of duplicate resources if we import the same data twice? Absolutely, but that’s by design.
import 和 export 方法并不是要成为一种将数据备份和恢复到 API 中的机制。该特殊功能需要对数据提供更严格的保证,例如备份数据时快照的一致性(如我们在第 23.3.4 节中看到的),以及导入数据时完全替换集合。而这些自定义方法没有这样保证。
The import and export methods are emphatically not intended to be a mechanism for backing up and restoring data into an API. That special functionality requires far stricter guarantees about the data, such as consistency of the snapshots when backing up data (as we saw in section 23.3.4), and full replacement of a collection when importing data. And these custom methods make no such guarantees.
所以到目前为止,自定义导入和导出方法都一次只关注一个资源,但这有点过于简单化了。毕竟,考虑到我们对资源布局和层次结构的讨论(参见第 4 章),可以很合理地预期我们可能想要导入或导出也有子资源或其他相关资源的资源。举个很简单的例子,如果我们想导出一个ChatRoom资源集合怎么办?这是否也应该包括所有子Message资源?ChatRoom显然聊天信息本身很重要,但是当我们考虑导入或导出聊天室时,我们可能不会想到空的。
So far, both the custom import and export methods have been focused exclusively on a single resource at a time, but that’s a bit of an oversimplification. After all, given our discussions on resource layout and hierarchy (see chapter 4), it’s pretty reasonable to expect that we may want to import or export resources that also have child or other related resources. Taking a very simple example, what if we wanted to export a collection of ChatRoom resources? Should that include all of the child Message resources as well? Obviously the chat information itself is important, but an empty ChatRoom probably isn’t what we have in mind when we think of importing or exporting chat rooms.
虽然我们可能急于尝试扭曲此模式以在单个 API 调用中适应对多种不同资源类型的操作,但很简单,这不是此模式的目的。正如我们在 23.1 节中了解到的,该模式的目标是弥合 API 服务和外部存储系统之间的差距,避免中间人在两者之间打乱数据。它无意引入关于如何从 API 本身检索数据或将数据加载到 API 本身的新的、更高级的功能。因此,正如我们将其他 API 方法限制为单一资源类型(例如,标准列表或批量创建方法)一样,自定义导入和导出方法也应该受到类似的限制。
While we might be eager to attempt twisting this pattern to accommodate operation on multiple different resource types in a single API call, this is, quite simply, not what this pattern is about. As we learned in section 23.1, the goal of this pattern is to bridge the gap between an API service and an external storage system, avoiding the middleman to shuffle data between the two. It is not intended to introduce new, more advanced functionality about how data is retrieved from or loaded into the API itself. Due to this, just as we restrict other API methods to a single resource type (e.g., the standard list or batch create methods), the custom import and export methods should be similarly restricted.
这意味着导入和导出数据几乎总是限于没有子资源并且可能在没有任何其他相关资源的情况下有用的资源。例如,虽然导入或导出资源可能没有意义ChatRoom,但能够导入或导出Message给定资源中包含的资源仍然非常有用ChatRoom。
This means that importing and exporting data is almost always limited to resources that have no children and are likely to be useful without any additional related resources. For example, while it probably doesn’t make sense to import or export ChatRoom resources, it is still quite useful to be able to import or export the Message resources contained in a given ChatRoom resource.
换句话说,当我们导入或导出Message资源时,重点是将有用的、半独立的数据移入和移出 API。当我们对ChatRoom资源做同样的事情时,我们的重点似乎已经从简单的数据洗牌和路由转移到备份和恢复功能的世界,我们希望保留数据的特定状态并可能恢复系统在稍后的时间点回到那个状态。虽然这种保存和恢复的想法当然很重要,而且它恰好与进口和出口的想法有很多重叠,但这两者肯定不是一回事。
Put a bit differently, when we are importing or exporting Message resources, the focus is on moving useful, semi-independent data into and out of an API. When we do the same with a ChatRoom resource, our focus appears to have shifted away from the simple shuffling and routing of data and into the world of backup and restore functionality, where we want to preserve a specific state of the data and potentially restore the system back to that state at a later point in time. While this idea of preservation and restoration is certainly an important one, and it happens to have quite a bit of overlap with the idea of importing and exporting, these two are most certainly not the same thing.
让我们换个话题,考虑一下我们如何处理在任何情况下不可避免地出现的意外情况。应用程序接口。
Let’s change topics and consider how we might deal with the unexpected as it inevitably arises in any API.
只是由于任何系统都可能不时遇到问题,那些涉及两个系统传输潜在大量数据的系统几乎肯定会出现问题。此外,由于自定义导入和导出方法所期望的行为本质上是关于在一个地方或另一个地方写入数据,我们有一个更复杂的场景:如果在读取或写入数据(API 中的资源或bytes in external storage system),故障几乎肯定会更难恢复。
Just as any system is likely to run into problems from time to time, those that involve two systems transferring potentially large amounts of data are almost guaranteed to do so. Additionally, since the behavior expected by the custom import and export methods is inherently about writing data in one place or another, we have a more complicated scenario: if a failure occurs in the middle of reading or writing data (either resources in the API or bytes in external storage system), the failure will almost certainly be more difficult to recover from.
这些方法中的任何一种的失败都可能有两种形式,以数据的流动方式为导向。导入数据时,当存储系统无法向API服务提供原始字节或API服务无法创建新资源时,可能会出现故障。导出数据时,故障类似,但方向相反,是由于无法从 API 读取资源或无法将原始字节传输和写入外部存储系统而引起的。我们如何解决这些失败?更重要的是,如果某个操作碰巧失败了,我们如何才能知道重试该操作是否安全?让我们首先解决两者中更容易的问题。
Failures from either of these methods can come in two flavors, oriented around which way the data is flowing. When importing data, a failure might arise when the storage system is unable to provide raw bytes to the API service or if the API service is unable to create new resources. When exporting data, the failures are similar but in the other direction, arising from an inability to read resources from the API or to transport and write raw bytes into the external storage system. How do we address these failures? And more importantly, how can we know whether it’s safe to retry an operation if it happens to fail? Let’s address the easier of the two first.
尽管任何类型的失败都是不便的,关于导出失败的好消息是重试通常是安全的。原因是从 API 导出数据的另一次尝试完全独立于先前的(可能失败的)尝试。但这并不意味着这种类型的失败没有并发症。有一些潜在的问题需要解决。
While failures of any kind are inconvenient, the good news about an export failure is that it’s generally safe to retry. The reason is that another attempt at exporting the data from an API is completely independent from an earlier (potentially failed) attempt. This doesn’t mean this type of failure is without complications, though; there are a few potential issues that need to be addressed.
第一个是最明显的:不能保证一次导出尝试的数据与以后尝试的数据相同。特别是在有许多并发用户与系统交互的易变数据集中,这两种尝试不太可能产生相似的结果。幸运的是,导出方法的目标是将数据传输出 API,它可能已经无法保证导出数据的一致性。相反,正如我们在第 23.3.4 节中了解到的那样,数据很可能是资源的污点,而不是单个一致的快照,因此重复导出尝试不应导致结果数据出现任何有意义的问题。
First is the most obvious: the data of one export attempt is not guaranteed to be identical to that of a later attempt. Particularly in volatile data sets with many concurrent users interacting with the system, it’s very unlikely that the two attempts will yield similar results. Luckily, the goal of the export method is to transport data out of the API, and it already may make no guarantees about the consistency of the data being exported. On the contrary, as we learned in section 23.3.4, the data is likely to be a smear of resources rather than a single consistent snapshot, so a repeat export attempt shouldn’t result in any meaningful problems with the resulting data.
接下来,我们不得不想知道如何处理之前失败的导出尝试中的数据。我们应该尝试删除它还是留下它以便稍后手动清理?尽管看起来很浪费,但最好的做法通常是不理会数据,无论故障发生在流程的哪个位置。
Next, we have to wonder what to do with the data from a previous failed export attempt. Should we attempt to delete it or leave it around to be manually cleaned up later? As wasteful as it might seem, the best practice is generally to leave the data alone, regardless of where in the process the failure occurred.
这样做的原因有两个。首先,无法保证 API 甚至具有从外部存储系统删除数据的权限(在这种情况下最好拒绝该权限)。其次,我们不知道实际导出的数据对于请求导出的用户来说是否有价值。
The reason for this is two-fold. First, there’s no guarantee that the API will even have permission to delete data from the external storage system (and it’s best practice to deny that permission in this case). Second, we have no idea whether the data that was actually exported is valuable or worthless to the user requesting the export.
这似乎违反直觉,但归根结底,我们无法判断导出的数据量是否足以满足用户的目的。显然 100% 有用而 0% 没用,但介于两者之间的一切都是一个完整的灰色区域。例如,假设我们正在导出一个非常大的 10 TB 数据集。需要导出多少数据,我们才能判断它越过了从无价值到有价值的界限?如果最后一个资源的导出操作失败,真的值得完全删除吗?也许这种资源在宏伟的计划中并不是那么重要。最重要的是,删除导出数据不属于导出数据的范围,应该由存储系统的所有者来决定如何处理数据。
This might seem counterintuitive, but the bottom line is that we aren’t in a position to judge how much data being exported is sufficient for the user’s purposes. Obviously 100% is useful and 0% is not, but everything in between is a complete gray area. For example, imagine we’re exporting a very large 10 terabyte data set. How much data needs to be exported before we judge it to cross the line from worthless to valuable? If the export operation fails on the very last resource, is it really worth deleting entirely? Maybe that one resource wasn’t all that important in the grand scheme of things. The bottom line is that deleting export data is outside the scope of exporting data, and it should be left to the owner of the storage system to decide what to do with the data.
鉴于此,我们有一个新问题:如果存储系统空间不足怎么办?不幸的是,考虑到之前关于将决定权留给用户的讨论,这是一个我们无法解决的问题。更糟糕的是,这可能会导致级联故障,即由于存储空间不足而导致重复的导出操作失败。也就是说,考虑到我们迄今为止建立的约束和原则,这只是我们别无选择只能接受的情况,因为对于 API 用户而言,替代方案从根本上来说更糟糕。
Given this, we have a new problem: what if the storage system runs out of space? Unfortunately, given the previous discussion about leaving the decision to the user, this is a problem we’re just not in a position to solve. Even worse, this might lead to cascading failures where a repeated export operation will fail due to a lack of storage space. That said, given the constraints and principles we’ve established thus far, this is simply a case we have no choice but to live with, as the alternative is fundamentally worse for API users.
现在我们已经介绍了导出数据时的故障,让我们换个方向,看看当数据流向另一个时如何处理故障方法。
Now that we’ve covered failures when exporting data, let’s switch directions and look at what to do about failures when data flows the other way.
不像导出失败,导入数据时的失败不是我们可以简单地重试并假设一切都很好的事情。首先,失败可能是由于验证错误而不是暂时的网络或数据传输错误引起的。如果是这种情况,无论重试多少次都无法解决问题,我们应该在 LRO 结果中报告验证失败的条目。
Unlike export failures, a failure when importing data isn’t something we can simply retry and assume all is well. First, it’s possible that the failure might have arisen due to a validation error rather than a transient networking or data transport error. If this is the case, no amount of retrying will resolve the problem and we should report the entries that failed validation in the LRO results.
即使失败是暂时性的结果,我们也必须认识到之前失败的数据导入尝试可能仍然创建了一些资源这一事实。这是一个大问题,因为除非导入立即失败,否则将来重试导入操作的尝试实际上肯定会导致创建重复的资源。我们可以做什么?
Even if the failure is the result of something transient, we have to be cognizant of the fact that a previously failed attempt to import data may have still created some resources. This is a big problem because, unless the import failed immediately, a future attempt to retry the import operation is practically guaranteed to result in duplicate resources being created. What can we do?
一个明显的解决方案是在事务内执行导入操作,在出现任何类型的故障时回滚事务。虽然这是解决问题的快速简单方法,但不幸的是,并非每个存储系统都支持完整的事务语义。甚至那些这样做的人也可能会遇到大量数据的问题,导致在单个事务中创建大量资源。
One obvious solution is to execute the import operation inside a transaction, rolling back the transaction at the sign of any sort of failure. While this is a quick and simple fix for the problem, the unfortunate fact is that not every storage system will support full transactional semantics. And even those that do may have trouble with excessively large amounts of data, leading to monstrous numbers of resources being created inside a single transaction.
对于我们不能简单地依赖于有价值的数据库功能的情况,我们显然需要另一种解决方案。为此,我们可以借鉴第 26 章的内容,保护我们的 API 免受重复 API 请求的危害。
For the cases where we cannot simply fall back on a valuable piece of database functionality, we’ll clearly need another solution. For this, we can take a page out of chapter 26 and protect our API from the perils of duplicate API requests.
虽然有很多关于请求重复数据删除的内容需要阅读和理解,但简单的概述是,对于将要导入的每条记录,我们可以分配一个全局唯一标识符,称为导入请求标识符。这个importRequestId将分配给将在导入操作期间创建的每个单独的资源,并且 API 服务可以在第一次创建时缓存标识符,丢弃任何未来导入同一记录的尝试。
While there’s quite a lot to read and understand about request deduplication, the simple overview is that for each record that will be imported, we can assign a single globally unique identifier called an import request identifier. This importRequestId will be assigned to each individual resource that will be created during an import operation, and the API service can cache the identifier when it’s created the first time, discarding any future attempts to import this same record.
清单 23.7 一些带有重复数据删除导入请求 ID 的示例 JSON 资源
Listing 23.7 Some sample JSON resources with import request IDs for deduplication
{ importRequestId: "abc", sender: "users/1", content: "hi" } ❶ { importRequestId: "def", sender: "users/2", content: "hi back" }
{ importRequestId: "abc", sender: "users/1", content: "hi" } ❶ { importRequestId: "def", sender: "users/2", content: "hi back" }
❶ API 使用导入请求 ID 来消除歧义并避免为给定的数据条目创建重复的资源。
❶ The import request ID is used by the API to disambiguate and avoid creating duplicate resources for a given data entry.
为了让那些可能想要在导出数据后重新导入数据的人更友好,我们甚至可以考虑在MessageOutputConfig界面中添加一个配置选项以支持importRequestId在导出操作期间是否应将这些值注入到序列化数据中。不管我们是否这样做,关键是如果我们想确保在重试失败的导入操作时不会导入重复的资源,则必须有一种方法来区分我们之前看到的资源条目和我们没有看到的资源条目之间的区别't。正如我们在第 23.3.5 节中了解到的,由于某些非常充分的理由已经忽略了资源 ID,因此我们为此目的引入了一些新内容。这意味着如果输入中不存在此特殊字段,则在不支持底层存储系统中的事务的系统中重试失败的导入请求最终将导致重试导入操作创建复制资源。
To make this friendlier for those who might want to re-import data after it’s been exported, we might even consider adding a configuration option in the MessageOutputConfig interface to support whether these importRequestId values should be injected into the serialized data during an export operation. Regardless of whether we do that, the point is that if we want to ensure that duplicate resources are not imported when retrying failed import operations, there must be a way to tell the difference between a resource entry we’ve seen before and one we haven’t. Since the resource ID is already being ignored for some very good reasons, as we learned in section 23.3.5, this leads us to introduce something new for just this purpose. This means that if this special field is not present on input, retrying a failed import request in a system that doesn’t have support for transactions in the underlying storage system will ultimately lead to the retried import operation creating duplicate resources.
作为我们在第 22 章中看到,过滤资源集合的能力是一项有价值的功能,它使我们能够更有效地与存储在 API 中的数据进行交互。但是这种过滤资源的想法是否同样适用于自定义导入和导出方法,就像它适用于标准列表方法一样?在这种情况下,答案是肯定的和否定的。
As we saw in chapter 22, the ability to filter a collection of resources is a valuable piece of functionality that enables us to interact more efficiently with the data stored in an API. But does this idea of filtering resources apply just as well to the custom import and export methods as it does to the standard list method? The answer, in this case, is both yes and no.
什么时候谈到导出数据,回想一下,目标无非是提供一种将存储系统直接连接到 API 的方法。换句话说,导出方法应该与标准列表方法没有什么不同。所以按理说自定义导出方式应该是支持先过滤再导出的。而且,如您所料,API 定义并不比在导出请求接口上添加筛选字段复杂。
When it comes to exporting data, recall that the goal was nothing more than providing a way to connect a storage system directly to the API. In other words, the export method should be doing nothing different from a standard list method. Therefore, it stands to reason that the custom export method should most certainly support filtering items before exporting them. And, as you’d expect, the API definition for this is no more complicated than adding a filter field on the export request interface.
Listing 23.8 Example of adding a filter to an export request
interface ExportMessagesRequest { parent: string; outputConfig: MessageOutputConfig; dataDestination: DataDestination; filter: string; ❶ }
❶ We can filter the exported resources with a filter string field.
您可能想知道为什么将过滤器放置在界面旁边(而不是在OutputConfig界面内部). 答案很简单,输出配置只关注我们获取一堆资源并将它们转化为一堆字节的方式。另一方面,过滤是在此阶段之前发生的事情,重点是选择哪些资源将被序列化、压缩等。虽然将此配置放在 中可能很诱人OutputConfig,但此处的关注点分离至关重要,确保每个部分负责一个(且仅一个)事物。
You may be wondering why the filter is placed next to (rather than inside of) the OutputConfig interface. The answer is simply that the output configuration is focused exclusively on the way we take a bunch of resources and turn them into a bunch of bytes. Filtering, on the other hand, is something that happens before this stage and focuses on selecting which resources will be serialized, compressed, and so on. While it might be tempting to put this configuration inside the OutputConfig, the separation of concerns here is critical, ensuring that each piece is responsible for one (and only one) thing.
不像导出数据,我们对 API 选择的数据应用过滤器,在门口过滤数据有点不寻常。它涉及将字节从外部存储系统传输到 API 服务,然后 API 服务应用存储在InputConfig到序列化的字节(例如,解压缩、解密和反序列化),然后才应用过滤器来决定是丢弃资源还是在 API 中创建资源。
Unlike exporting data, where we apply a filter on the data being selected by the API, filtering data on the way in the door is a bit more unusual. It involves transporting the bytes from the external storage system to the API service, then the API service applying the details stored in the InputConfig to the serialized bytes (e.g., decompressing, decrypting, and deserializing), and only then applying a filter to decide whether to throw away the resource or create it in the API.
另一种情况是,导入的资源中可能存在一些派生数据,这些数据仅在加载到 API 中后才可用于过滤。例如,我们将如何根据仅输出字段过滤传入数据,例如createTime或任何其他服务计算属性?为了有效地做到这一点,我们需要通过 API 本身的业务逻辑来运行数据,这将导致更加复杂并可能浪费精力。
To throw another wrench into the mix, it’s also possible that there may be some derived data on the resources being imported that are only be available for filtering after they’ve been loaded into the API. For example, how would we go about filtering incoming data based on an output-only field such as createTime or any other service-computed property? To do so effectively, we’d need to run the data through the business logic of the API itself, which would result in even more complexity and potentially wasted effort.
您可能会猜到,虽然在导入操作期间支持过滤入口数据当然不是不可能,但这是我们通常不应该在 API 中做的事情之一。即使 API 没有遇到当前列出的问题,也不能保证这个事实在未来仍然存在。如果我们确实进行了导致这种情况的更改,那么我们现在就无法确定如何在一些潜在的棘手情况下继续支持导入过滤器。相反,用户应该在执行之前负责任何数据转换(包括过滤)进口手术。
As you might guess, while it’s certainly not impossible to support filtering of ingress data during an import operation, it is one of those things we generally should not do in an API. Even if the API doesn’t run into the problems listed currently, there’s no guarantee that this fact will remain true in the future. And if we ever do make changes that lead to this scenario, we’re now stuck figuring out how to continue supporting filters on imports in some potentially tricky situations. Instead, the user should be responsible for any data transformation (including filtering) before executing an import operation.
这以下 API 定义涵盖了我们如何导入和导出Message资源在我们的聊天 API 中。有两个示例源和目标涵盖 Samba ( https://en.wikipedia.org/wiki/Samba ) 存储系统和亚马逊的S3( https://aws.amazon.com/s3/ )。
The following API definition covers how we might import and export Message resources in our chat API. There are two example sources and destinations covering a Samba (https://en.wikipedia.org/wiki/Samba) storage system and Amazon’s S3 (https:// aws.amazon.com/s3/).
Listing 23.9 Final API definition
abstract class ChatRoomApi { @post("/{parent=chatRooms/*}/messages:export") ExportMessages(req: ExportMessagesRequest): Operation<ExportMessagesResponse, ExportMessagesMetadata>; @post("/{parent=chatRooms/*}/messages:import") ImportMessages(req: ImportMessagesRequest): Operation<ImportMessagesResponse, ImportMessagesMetadata>; } interface ExportMessagesRequest { parent: string; outputConfig: MessageOutputConfig; dataDestination: DataDestination; filter: string; } interface ExportMessagesResponse { chatRoom: string; messagesExported: number; // ... } interface ExportMessagesMetadata { chatRoom: string; messagesExported: number; // ... } interface MessageOutputConfig { // The content type for serialization. // Choices: "json", "csv", undefined for default. contentType?: string; // Use ${number} for a zero-padded file ID number. // Content type will be appended with file extension (e.g., ".json"). // Default: "messages-part-${number}" filenameTemplate?: string; // Undefined for no maximum file size. maxFileSizeMb?: number; // Choices: "zip", "bz2", undefined (not compressed) compressionFormat?: string; } interface DataDestination { // A unique identifier of the destination type (e.g., "s3" or "samba") type: string; } interface SambaDestination extends DataDestination { // The location of the Samba share (e.g., "smb://1.1.1.1:1234/path") type: 'samba'; uri: string; } interface S3Destination extends DataDestination { type: 's3'; bucketId: string; objectPrefix?: string; } interface ImportMessagesRequest { parent: string; inputConfig: MessageInputConfig; dataSource: DataSource; } interface ImportMessagesResponse { chatRoom: string; messagesImported: number; } interface ImportMessagesMetadata { chatRoom: string; messagesImported: number; } interface MessageInputConfig { // The content type of the input. // Choices: "json", "csv", undefined (auto-detected) contentType?: string; // Choices: "zip", "bz2", undefined (not compressed) compressionFormat?: string; } interface DataSource { type: string; } interface SambaSource extends DataSource { type: 'samba'; uri: string; } interface S3Source extends DataSource { type: 's3'; bucketId: string; // One or more masks in glob format (e.g., "folder/messages.*.csv") mask: string | string[]; }
甚至尽管这种模式似乎涵盖了与从 API 获取数据和从 API 获取数据相关的所有内容,但也许最令人困惑的是它实际上是一个非常具体且重点狭窄的设计,涵盖了一种移动数据和移动数据的独特方式。试图解决一个简单的目标:弥合 API 服务和外部存储系统之间的差距。这种特异性和狭隘的关注点导致我们有两个主要缺点。
Even though it might have seemed a bit like this pattern would cover everything related to getting data in and out of an API, perhaps the most confusing thing is that it’s actually a very specific and narrowly focused design, covering a distinct way of moving data around and trying to solve a simple goal: bridge the gap between the API service and an external storage system. The result of this specificity and narrow focus leads us to two major drawbacks.
首先,该模式不是为处理多种资源类型而设计的。这意味着它不会在这里帮助您导出大量相关资源、父资源和子资源,或者任何导致多资源关系的一般情况。这种模式非常简单,就是获取大量独立的数据点,并帮助在 API 服务和外部存储系统之间移动它们,而无需应用程序服务器充当中间人。如果您确实需要将多种资源连接在一起并以任何有意义的方式操纵这些资源的能力,那么这个中间人就不再只是一个中间人;它已成为业务逻辑的重要组成部分。自定义导入和导出方法无意包含或实现任何特殊的业务逻辑;它们被设计成简单的数据移动器。
First, the pattern is not designed to handle more than one resource type. This means it’s not here to help you with exporting big chunks of related resources, parent and child resources, or anything in general that results in multi-resource relationships. This pattern is, quite simply, about taking lots of self-contained data points and helping to shuffle them between an API service and an external storage system without an application server acting as the middleman. If you do indeed need the ability to connect multiple resources together and manipulate those resources in any meaningful way, this middleman is no longer just a middleman; it’s become an important piece of business logic. The custom import and export methods are not intended to contain or implement any special business logic; they’re designed to be simple data movers.
这导致我们面临下一个主要缺点:很容易将导入和导出与备份和恢复功能混淆(毕竟,它们通常都涉及在 API 和存储系统之间洗牌数据)。正如我们在 23.3.4 节中看到的,这些自定义方法根本不是为了解决数据的原子保存或从以前的快照完全恢复数据的任务而设计的。相反,它们在没有一致性保证的情况下运行,如果没有完全理解,可能会导致一些非常混乱的问题结果。
This leads us to the next major drawback: it’s easy to confuse import and export with backup and restore functionality (after all, they both typically involve shuffling data between an API and a storage system). As we saw in section 23.3.4, these custom methods are not at all designed to address the task of atomic preservation of data or complete restoration of data from a previous snapshot. Instead, they operate with no consistency guarantees and, if not fully understood, can lead to some pretty confusing results.
Why is it important to use two separate interfaces for configuring an import or export method? Why not combine these into a single interface?
What options are available to avoid importing (or exporting) a smear of data? What is the best practice?
When a resource is exported, should the identifier be stored along with the data? What about when that same data is imported? Should new resources have the same IDs?
If an export operation fails, should the data that has been transferred so far be deleted? Why or why not?
If a service wanted to support importing and exporting, including child and other related resources, how might it go about doing so? How should identifiers be handled in this case?
The custom import and export operations allow us to move data directly between an API service and an external storage system.
These methods are not intended to act like backup and restore functionality and have several consequences arising from this key difference.
API 定义侧重于两个正交配置接口:一个用于将字节移入和移出外部存储系统,另一个用于将这些字节与 API 资源表示形式相互转换。
The API definitions focus on two orthogonal configuration interfaces: one for moving bytes in and out of the external storage system and another for converting those bytes to and from API resource representations.
Unless the system supports point-in-time data loading, it’s very possible that the custom import and export methods will lead to a smear of data stored in the system.
Importing and exporting data should generally be limited to a single resource type at a time, relying on backup and restore functionality to handle child and other referenced resources.
人们很容易忘记用户在使用 API 时偶尔会犯错误。不幸的是,由于这些错误是不可避免的,因此设计一个允许用户在错误发生时帮助将损失降至最低的 API 非常重要。在接下来的几章中,我们将研究旨在做到这一点的设计模式。
It’s easy to forget that users will occasionally make mistakes when using an API. Since these mistakes are unfortunately inevitable, it’s important to design an API that allows users to help minimize the damage when they happen. In the next several chapters we’ll look at design patterns aimed at doing just that.
在第 24 章中,我们将探索版本控制和兼容性的高级概念以及可用于 API 版本控制的策略。在第 25 章中,我们将探讨 API 回收站的概念,以防止意外删除。在第 26 章到第 28 章中,我们将探讨防止重复工作、在执行工作之前测试请求以及保留资源更改历史以防用户需要撤消更改的各种策略。
In chapter 24, we’ll explore the high-level concepts of versioning and compatibility and the strategies available for versioning an API. In chapter 25, we’ll look at the idea of an API recycle bin of sorts to prevent accidental deletion. In chapters 26 through 28, we’ll look at various strategies for preventing duplicate work, testing requests before executing work, and keeping a history of changes to resources in case users ever need to undo their changes.
在第 29 章中,我们将探讨在出现网络故障或其他问题时何时以及如何安全地重试请求。最后,在第 30 章中,我们将探讨 API 服务如何安全地验证请求。
In chapter 29, we’ll explore when and how to safely retry requests in the event of network failures or other issues. And finally, in chapter 30, we’ll explore how an API service can safely authenticate requests.
我们构建的软件很少是静态的。相反,随着新特性的创建和新功能的添加,它往往会随着时间的推移而频繁变化和发展。这导致了一个问题:我们如何进行这些改进,而又不会给那些已经开始依赖事物的外观和行为的用户带来严重不便?在本章中,我们将探讨解决此问题的最常见机制:版本控制。
The software we build is rarely static. Instead, it tends to change and evolve frequently over time as new features are created and new functionality is added. This leads to a problem: how do we make these improvements without seriously inconveniencing users who have come to depend on things looking and acting as they did before? In this chapter, we’ll explore the most common mechanism for addressing this problem: versioning.
软件开发不仅很少是静态的,而且很少是一个连续的过程。即使在持续集成和部署的情况下,我们也经常会在一些新功能“上线”并且对用户可见的地方设置检查点或启动,这对于 Web API 尤其如此。API 既严格又公开,这意味着很难安全地进行更改,这一事实加剧了这种需求。这就引出了一个明显的问题:我们如何才能在不对已经使用该 API 的用户造成损害的情况下对 Web API 进行更改?
Not only is software development rarely static, it is also rarely a continuous process. Even with continuous integration and deployment, we often have checkpoints or launches where some new functionality “goes live” and is visible to users, which holds especially true for web APIs. This need is exacerbated by the fact that APIs are both rigid and public, meaning changes are difficult to make safely. This leads to an obvious question: how can we make changes to a web API without causing damage to those using the API already?
默认的“用户自行处理”选项显然是不能接受的。此外,“永远不要更改 Web API”的替代方案实际上是不可能的,即使我们从未计划添加新功能。例如,如果存在安全或法律问题(例如,律师通知您有问题的 API 调用在某种程度上违反了法律),则更改将不可避免。为解决这个问题,本模式将探讨如何将版本的概念引入 API 以及适合 Web API 广泛需求的各种不同策略。
The default option of “users will deal with it” is clearly not acceptable. Additionally, the alternative of “just never change the web API” is practically impossible, even when we never plan to add new functionality. For example, if there were a security or legal issue (e.g., a lawyer notifies you that the API call in question is somehow breaking the law), the change would be unavoidable. To address this, this pattern will explore how to introduce the concept of versions to an API along with a variety of different strategies that suit the wide spectrum of requirements for web APIs.
为了确保 API 的现有用户不受后续更改的影响,API 可以引入检查点或 API 版本,为每个不同的版本维护单独的部署。这意味着通常可能会给 API 的现有用户带来问题的新更改将作为新版本而不是对现有版本的更改进行部署。这有效地向尚未准备好进行这些更改的用户子集隐藏了更改。
In order to ensure that existing users of an API are unaffected by subsequent changes, APIs can introduce checkpoints or versions of their API, maintaining separate deployments for each different version. This means that new changes that might typically cause problems for existing users of the API would be deployed as a new version rather than as changes to the existing version. This effectively hides changes from the subset of users that is not ready for these changes.
不幸的是,这不仅仅是将 Web API 的不同部署标记为单独的版本并调用已解决的问题。我们还必须担心可以实现版本控制的不同级别(例如,客户端库与有线协议)以及从许多可用选项中选择哪种版本控制策略。也许最具挑战性的方面是真的没有正确答案。相反,选择如何在 Web API 中实现版本控制将因构建 API 的人以及在另一端使用 API 的人的期望和配置文件而异。
Unfortunately, there’s far more to this than just labeling different deployments of a web API as separate versions and calling the problem solved. We also have to worry about the different levels at which versioning can be implemented (e.g., client libraries versus wire protocol) as well as which versioning policy to choose out of the many available options. Perhaps the most challenging aspect is that there’s really no right answer. Instead, choosing how to implement versioning in a web API will vary with the expectations and profiles of those building the API, as well as those using it on the other side.
然而,有一件事保持不变:版本控制的主要目标是为 API 用户提供尽可能多的功能,同时将不便降至最低。牢记这个目标,让我们首先探索我们可以做些什么,通过检查更改是否可以被认为是兼容的,来最大程度地减少不便。
There is, however, one thing that remains invariant: the primary goal of versioning is to provide users of an API with the most functionality possible while causing minimal inconvenience. Keeping this goal in mind, let’s start by exploring what we can do to minimize inconvenience by examining whether changes can be considered compatible.
兼容性是两个不同的组件是否可以成功地相互通信的区别。在 Web API 的情况下,这通常是指客户端和服务器之间的通信。
Compatibility is the distinction of whether two different components can successfully communicate with one another. In the case of web APIs, this generally refers to communication between a client and a server.
这个概念可能看起来很简单,但当我们考虑兼容性的时间方面时,它会变得复杂得多。当您启动 Web API 时,任何客户端代码显然都将与 API 兼容,但不幸的是,API 和客户端都不会是静态的或及时冻结的。随着情况的变化,我们开始看到更多客户端和服务器的组合,我们必须考虑这些不同的组合是否能够相互通信,这并不像听起来那么简单。例如,如果您有一个 API 客户端的三个迭代和三个不同的 API 服务器版本可用,那么您实际上总共有九个通信路径需要考虑,但并非所有路径都可以正常工作。
This concept might seem elementary, but it gets far more complicated when we consider the temporal aspects of compatibility. When you launch a web API, any client-side code will, clearly, be compatible with the API, but, unfortunately, neither APIs nor clients tend to be static or frozen in time. As things change and we start seeing more combinations of clients and servers, we have to consider whether these different combinations are able to communicate with one another, which is not as simple as it sounds. For example, if you have three iterations of an API client and three different API server versions available, you actually have nine total communication paths to think about, not all of which are expected to work.
由于 API 设计者没有能力控制 API 用户编写的客户端代码,因此我们必须关注对 API 服务器所做的更改是否与现有客户端代码兼容。换句话说,如果您可以将一个版本换成另一个,并且任何现有的客户端代码都不会注意到差异(或者至少不会停止运行),我们将调用两个彼此兼容的 API 服务器版本。
Since API designers don’t have the ability to control client-side code written by users of the API, we instead must focus on whether changes made to the API server are compatible with existing client code. In other words, we’d call two API server versions compatible with one another if you could swap out one for the other and any existing client-side code wouldn’t notice the difference (or at least wouldn’t stop functioning).
例如,假设 Web API 的用户编写了一些与 API 对话的代码(直接或通过客户端库)。现在假设我们有一个新版本的 Web API 服务(在本例中为 v1.1),如图 24.1 所示。如果在流量从 v1 转移到 v1.1 后客户端代码继续工作,我们会说这两个版本是兼容的。
For example, let’s imagine that a user of a web API has written some code that talks to the API (either directly or through a client library). Let’s now imagine that we have a new version of the web API service (in this case, v1.1), shown in figure 24.1. We’d say that these two versions are compatible if the client code continues working after traffic is shifted away from v1 and over to v1.1.
Figure 24.1 Experiment to determine whether two versions are compatible
那么我们为什么要关心兼容性呢?如果您还记得本节前面的内容,我们了解到版本控制是达到目的的一种手段:为我们的用户提供最大数量的功能,同时尽可能减少不便(理想情况下完全没有不便)。我们这样做的一种方法是将新版本与现有版本一起部署,这样用户就可以访问新功能,而不会导致任何以前编写的代码被其他人破坏。这是可行的,因为我们使用新的 API 版本有效地创建了一个全新的世界,但这真的是我们能做的最好的吗?如果有更好的方法可以在不造成任何不便的情况下将更多功能交到现有用户手中怎么办?
So why do we care about compatibility? If you recall earlier in this section, we learned that versioning is a means to an end: to provide the maximum amount of functionality to our users while causing as little inconvenience as possible (and ideally no inconvenience at all). One way we do this is by deploying new versions alongside existing ones so that users can access new functionality without causing any previously written code to break for others. This works because we effectively create a whole new world with a new API version, but is this really the best we can do? What if there was an even better way to get more functionality into the hands of existing users without causing any inconvenience?
事实证明,我们可以通过将新功能注入现有版本,使 API 本身与以前的样子足够接近,从而做得更好。修改后的版本非常接近,事实上,客户甚至可能无法区分添加新功能前后的 API。这意味着,如果我们只对兼容的 API 进行更改,那么所有现有代码将继续运行,而无需担心我们的更改。
It turns out that we can do better by injecting new functionality into an existing version in such a way that the API itself is close enough to how it looked before. The modified version would be so close, in fact, that a client may not even be able to tell the difference between the API before and after adding the new functionality. This means that if we only ever make changes to an API that are compatible, all existing code will continue functioning in blissful ignorance of our changes.
但是现在这将我们引向一个非常复杂和错综复杂的问题:我们如何确定给定的更改是否向后兼容?换句话说,我们如何决定一个变化是否会被注意到或破坏现有的客户端代码?
But now this leads us to a very complicated and intricate problem: how do we decide whether a given change is backward compatible? In other words, how do we decide whether or not a change will be noticeable or break existing client-side code?
作为 我们刚刚看到,仅对 API 进行向后兼容更改的能力意味着我们拥有无需做任何工作即可为用户提供新功能的神秘力量。所以现在我们要做的就是决定什么是向后兼容的变化。通常,这个决定很容易。我们只问自己一个简单的问题:更改是否会导致现有代码中断?如果是这样,那么它就会崩溃,或者向后不兼容。
As we just saw, the ability to make only backward compatible changes to an API means that we have the mystical power to provide new functionality to users without the need to do any work whatsoever. So now all we have to do is decide what exactly constitutes a backward compatible change. Often, this decision is easy. We just ask ourselves a simple question: does the change cause existing code to break? If so, then it’s breaking, or backward incompatible.
事实证明,这还不是全部,不幸的是,这是 API 设计中没有单一简单答案的场景之一。相反,我们剩下的是许多不同的答案,这些答案都可以说是正确的。此外,正确程度将取决于依赖您的 API 的用户以及他们的期望。换句话说,与本书中的许多其他设计模式不同,这是没有单一清晰明显的应用模式的最佳方法的案例之一。
It turns out that that’s not quite the whole story, and, unfortunately, this is one of those scenarios in API design where there is no single easy answer. Instead, what we’re left with is lots of different answers that are all arguably correct. Further, the degree of correctness will depend on the users who rely on your API and what their expectations are. In other words, unlike the many other design patterns in this book, this is one of those cases where there is no single clear and obvious best way to apply the pattern.
API 设计者在制定应该和不应该向后兼容的策略时,肯定有几个主题应该考虑。换句话说,虽然本节不会提供有保证的正确答案,但它会提出几个真正应该回答的不同问题。任何决定都不可能是完全正确或错误的;但是,设计人员必须根据 API 用户的期望选择最佳选项。例如,使用 API 的大数据仓库与一组微型 IoT(物联网)设备有着截然不同的期望,我们稍后会看到。
There are certainly a few topics API designers should consider when setting a policy of what should and should not be considered backward compatible. In other words, while this section will not provide a guaranteed right answer, it will pose a few different questions that really should be answered. No decision is likely to be purely right or wrong; however, designers must choose the best option based on what the users of the API expect. For example, a big data warehouse using an API will have very different expectations from a fleet of tiny IoT (Internet of Things) devices, as we’ll see a bit later.
Let’s dive right in and start looking at the most common questions worth answering.
这 最明显的起点是您是否想考虑将这种增强现有版本的能力作为提供新功能的一种方式。换句话说,您的 API 的用户是否对稳定性要求如此严格,以至于他们希望每个版本都永远冻结?如果 API 的主要用户是银行,这可能是一个非常合理的期望。他们可能希望明确选择加入任何新版本,这样他们就必须实际更改代码才能利用任何新功能。这完全没问题。另一方面,如果 API 的用户是一群小型科技创业公司,他们可能更关心获得新功能,而不是单个 API 版本的稳定性。
The most obvious place to start is whether you want to even consider this ability to augment existing versions as a way of providing new functionality. In other words, are the users of your API so strict on their stability requirements that they want every single version frozen in time forever? If the main users of an API are banks, this might be a very reasonable expectation. They might want any new versions to be explicitly opt-in, such that they have to actually change their code to take advantage of any new features. And that’s perfectly fine. On the other hand, if the users of an API are a bunch of small tech startups, they might care a lot more about getting new functionality than the stability of a single API version.
如果您确实决定在现有版本中允许新功能,您可能需要更仔细地考虑新功能如何呈现给 API 用户。例如,新功能可以表示为现有资源的新字段、全新资源或可以执行的新操作。而这些中的每一个对用户都有不同的影响。
If you do decide that new functionality should be permitted inside an existing version, you may need to think more closely about how that new functionality appears to API users. For example, new functionality could be represented as new fields on existing resources, as entirely new resources, or as new actions that can be performed. And each of these has different effects on the users.
在现有资源上的新字段的情况下,这可能对具有非常严格的资源要求的任何用户产生有意义的影响。例如,想象一下可用内存非常有限的 IoT 设备使用的 API。如果将新字段添加到现有资源,则之前有效的 HTTPGET请求实际上可能会导致设备内存不足和进程崩溃。这在检索单个资源时可能看起来有些牵强,但想象一下添加一个可能包含大量信息的字段和列出所有资源的 IoT 设备。不仅每个资源的数据量可能会增加到超出预期的范围,而且根据响应列出项目而显示的资源数量,问题会被放大。
In the case of new fields on existing resources, this could have a meaningful effect on any users that have very strict resource requirements. For example, imagine an API used by an IoT device with very limited memory available. If a new field is added to an existing resource, a previously working HTTP GET request could actually cause the device to run out of memory and the process to crash. This might seem far-fetched when retrieving a single resource, but imagine adding a field that might contain a lot of information and the IoT device listing all the resources. Not only might the amount of data per resource increase beyond the expected boundaries, but the problem is magnified based on the number of resources showing up in response to listing the items.
在添加新资源或新 API 方法的情况下,现有代码不太可能(如果不是不可能)知道 API 的这些新方面;但是,这并不意味着我们完全没有任何问题。例如,假设客户端代码用于备份来自 API 的所有资源。如果创建了新资源,而现有代码不知道新资源,备份输出中显然会丢失它。虽然在许多情况下最好避免对这些类型的问题承担责任,但在某些情况下,禁止为此类情况添加新资源可能是合理的。
In the case of adding new resources or new API methods, it’s unlikely (if not impossible) that existing code will be aware of these new aspects of the API; however, this doesn’t mean we’re completely free from any issues. For example, imagine client-side code to back up all resources from an API. If a new resource is created and the existing code has no knowledge of the new resource, it will obviously be missing from the backup output. While in many cases it’s best to avoid taking on responsibility for these types of problems, there are scenarios where it might be reasonable to prohibit adding new resources for cases like these.
如果新添加的资源引入了某种新的依赖关系或与现有资源或字段的关系,事情就会变得更加复杂。例如,假设一个 API 引入了一个新的MessagePolicy子资源属于ChatRoom资源(默认情况下包括原始策略)。如果我们只能ChatRoom在先删除资源后删除该MessagePolicy资源,我们实际上是在迫使现有用户了解这一新变化,而不是让他们生活在对这一新功能一无所知的情况下,这可能是一个糟糕的选择。
Things get a bit dicier in cases where the newly added resource introduces some sort of new dependency or relationship with the existing resources or fields. For example, imagine that an API introduces a new MessagePolicy sub-resource belonging to a ChatRoom resource (and includes a primitive policy by default). If we can only delete a ChatRoom resource after we’ve first deleted the MessagePolicy resource, we’re effectively forcing existing users to learn about this new change rather than allowing them to live in ignorance of this new functionality, which could be a bad choice.
正如我们之前所讨论的,这些当然不是必须遵守的固定规则。相反,这些是任何 API 设计者在启动新 API 时都应该考虑和决定的潜在场景。最终,决定向现有 API 添加新功能是否安全与其说是科学,不如说是一门艺术,因此至少可以期望就该主题制定一致且明确的政策。
As we discussed before, these are certainly not firm rules that must be followed. Instead, these are potential scenarios that any API designer should consider and decide on when launching a new API. Ultimately, deciding whether it’s safe to add new functionality to an existing API is a bit more art than science, so the least that can be expected is consistent and clear policies articulated on the topic.
即使您决定以任何形式添加新功能都不是您想要考虑的安全向后兼容的事情,还有一个更难涵盖的主题:如何处理错误 修复。
Even if you decide that adding new functionality in any form isn’t something you want to consider safely backward compatible, there’s an even more difficult topic to cover: how to handle bug fixes.
什么时候 编写软件时,我们很少会在第一次尝试时就完全正确。更常见的是我们犯了一个错误,当别人发现错误时让他们指出错误,然后我们简单地回过头来改正错误。但是,正如您现在可能看到的那样,这不就是另一种形式的改进功能吗?如果是这样,我们是否必须在单独的版本中修复我们的错误?或者我们可以安全地将更改注入现有版本并将其称为向后兼容更改吗?您可能会猜到,这个问题的答案是“视情况而定”。
When writing software, we rarely get things perfectly right on the first try. It’s far more common that we make a mistake, have that mistake pointed out by someone else when they find it, and then we simply go back and fix the mistake. But, as you might be seeing now, isn’t that change just another form of improved functionality? If so, do we have to fix our mistakes in a separate version? Or can we safely inject that change into the existing version and call it a backward compatible change? As you might guess, the answer to this question is, “It depends.”
有些错误会非常明显地表现出来。当客户端发出特定的 API 请求时,服务会出错并返回可怕的 HTTP500 Internal Server Error响应代码. 在这些情况下,修复错误通常是向后兼容的,因为永远不应该有客户端代码假设给定的请求会导致 API 服务器上的内部错误,然后在它停止崩溃时失败。
Some bugs will present themselves very obviously. When a client makes a specific API request, the service has an error and returns the dreaded HTTP 500 Internal Server Error response code. In these cases, fixing the bug will usually be backward compatible because there should never be client-side code that assumes a given request will result in an internal error on the API server and then fail when it stops crashing.
但是更微妙的问题呢?如果一个特定的 API 请求成功,在它本应返回一个错误(例如,一个400 Bad Request错误)时返回了一个无意义的结果怎么办?更有可能的是,有人开始依赖这种错误但错误地成功的行为。如果我们“修复错误”,之前返回成功结果的客户端代码将开始看到错误结果。
But what about more subtle issues? What if a specific API request succeeds, returning a nonsensical result when it should instead have returned an error (e.g., a 400 Bad Request error)? It’s far more likely that someone has come to depend on this erroneous but mistakenly successful behavior. And if we “fix the bug,” client code that previously returned a successful result will start seeing an error result instead.
更进一步,考虑这个错误深埋在一些计算代码中的情况,修复它意味着结果数字会改变。有一天,当我们进行 API 调用时,Add(0.1, 0.2)它可能会返回0.30000000000000004;然后第二天我们用浮点运算修复了这个错误,它开始返回0.3。这是我们要考虑向后兼容的东西吗?
Taking this even further, consider the case where this bug is buried deep inside some calculating code and fixing it means that result numbers change. One day, when we make an API call like Add(0.1, 0.2) it might return 0.30000000000000004; then the next day we fix the bug with floating point arithmetic and it starts returning 0.3. Is this something we want to consider backward compatible?
Listing 24.1 Example of an API implementation with floating point arithmetic problems
class AdditionApi { Add(a: number, b: number): number { return a + b; ❶ } }
❶ This simply adds two floating point numbers together, resulting in a floating point bug.
Listing 24.2 Example where we fix the floating point issues using fixed-point math
const Decimal = require('decimal.js'); class AdditionApi { Add(a: number, b: number): number { return Number(Decimal(a).plus(Decimal(b))); ❶ } }
❶通过依赖定点结构,我们避免了浮点错误。但这是向后兼容的更改吗?
❶ By relying on a fixed-point construct, we avoid the floating point error. But is this a backward compatible change?
不幸的是,同样没有正确答案。一般来说,修复抛出错误的 API 调用对大多数用户来说是非常安全的。使 API 调用突然开始抛出错误可能不是一个好主意,特别是如果 API 的用户非常关心稳定性并且不希望他们的代码无缘无故地开始崩溃。对于更微妙的修复,它实际上取决于典型用户的个人资料和错误的影响。例如,如果错误真的只是将两个数字相加并且有一些非常接近但有一些奇怪的浮点错误,那么修复可能并不是那么重要。如果计算用于火箭发射轨迹并且精度非常重要,那么制定修复错误的策略可能是有意义的,
Unfortunately, yet again, there is no right answer. In general, fixing API calls that throw errors is pretty safe for most users. Making an API call suddenly start throwing errors is probably not a great idea, particularly if the users of the API care a lot about stability and would rather not have their code start breaking for no obvious reason. For the even more subtle fixes it really depends on the profile of the typical user and the impact of the bug. For example, if the bug really is just about adding two numbers together and having something that’s pretty close but has some weird floating point error, then perhaps it’s not all that important to fix. If the calculation is being used for rocket launch trajectories and the precision is critically important, then it probably makes sense to have a policy of fixing the bug, even if it leads to a change in results from one day to the next.
这将我们引向一个类似但不完全相同的值得讨论的问题:强制性 变化。
This leads us to a similar though not quite identical issue worth discussion: mandatory changes.
几乎 始终,添加新功能或更改 Web API 的决定权在我们自己。换句话说,事件的典型过程是我们,这个 Web API 的创建者,决定我们想要做出改变并着手将其付诸行动。但有时,我们可能会有一些工作是别人强加给我们的。例如,当欧盟 (EU) 通过其隐私法(通用数据保护条例; 通用数据保护条例;https://gdpr-info.eu/),为了服务居住在欧盟的用户,需要进行许多新的更改。由于其中许多要求都集中在网络上的数据保留和同意上,因此很可能需要对 API 进行更改以适应新法规。这就引出了一个明显的问题:这些更改应该被注入到现有版本中,还是应该作为一个新的、孤立的版本进行部署?换句话说,我们是否应该考虑为遵守 GDPR 中规定的规则所做的更改是向后兼容的?
Almost always, the decision to add new functionality or make changes to a web API is our own. In other words, the typical course of events is that we, the creators of this web API, decide we want to make a change and get to work putting that into action. Occasionally though, we may have some work forced on us by others. For example, when the European Union (EU) passed their privacy law (General Data Protection Regulation; GDPR; https://gdpr-info.eu/), there were many new changes that were required in order to serve users residing in the EU. And since many of these requirements were focused on data retention and consent on the web, it’s certainly possible that there were API changes that needed to be made to accommodate the new regulations. This leads to the obvious question: should those changes be injected into an existing version or should they be deployed as a new, isolated version? Put differently, should we consider the changes made to adhere to the rules set out in the GDPR to be backward compatible?
最终,这个决定取决于确切的变化是什么以及法律的规定,但考虑到谁在使用和依赖 API 本身,该变化是否被视为向后兼容仍有待讨论。在 GDPR 的情况下,您可以遵循他们的时间表并部署具有新更改的新版本,同时在 GDPR 生效之日之前让每个人都可以使用现有的、不合规的 API。届时,您可能会开始阻止来自在欧盟拥有注册地址(或从位于欧盟的 IP 地址发出)的客户发出的任何请求,要求这些基于欧盟的客户使用新版本的 API符合 GDPR。或者,考虑到违反 GDPR 的潜在风险和罚款,完全关闭不合规的 API 可能更有意义。
Ultimately, this decision depends on what, exactly, the changes are and what the law says, but it’s still open to discussion whether the change would be considered backward compatible given who is using and relying on the API itself. In the case of the GDPR, you might follow their timeline and deploy a new version with the new changes while leaving the existing, noncompliant API available to everyone up until the date at which the GDPR comes into effect. At that point, you might begin blocking any requests coming from customers that have a registered address in the EU (or made from an IP address located in the EU), requiring these EU-based customers to use the new version of the API that is GDPR compliant. Or it might make more sense to simply shut down the noncompliant API entirely given the potential risks and fines involved in GDPR violations. It should go without saying that security patches and other changes in that category are often mandated, although not always by lawyers.
虽然在某些情况下完全遵守所有法律是不可能的(例如,有传言说比特币区块链具有元数据存储材料,这在大多数国家都是非法的,而且区块链是专门构建的,因此您无法更改过去的数据),但几乎始终可以进行更改以遵守相关法律。实际上,问题之一是如何最好地进行这些强制更改(通常来自律师或其他主要非技术人员)而不会对 API 用户造成过度压力。换句话说,当这些类型的更改出现时,几乎不可能将 API 用户与您强制进行的更改隔离开来。问题之一是如何通过在可能的情况下提前通知并最大限度地减少现有用户所需的工作来最大限度地减少对这些用户的影响。 用户。
While there are cases that it’s simply impossible to be completely compliant with all laws (e.g., it’s rumored that the bitcoin blockchain has metadata storing material that’s illegal in most countries, and blockchains are built specifically so that you cannot alter past data), it’s almost always possible to make changes to comply with relevant laws. The question, really, is one of how best to make these mandated changes (often coming from lawyers or other primarily nontechnical people) without causing undue amounts of stress on API users. In other words, when these types of changes come up it’s almost impossible to insulate API users from the changes you’re mandated to make. The question is one of how to minimize the impact on those users by providing advance notice when possible and minimizing the work required of existing users.
潜水 在兔子洞的更深处,我们可以开始研究其他可能会或可能不会被视为向后兼容的细微变化类型。这通常包括更深层次的变化,例如性能优化或其他一般微妙的功能变化,这些变化既不是错误修复也不一定是新功能,但仍然是响应 API 请求并以某种方式导致不同结果的底层系统的修改。
Diving further down the rabbit hole, we can start to look at other subtle types of changes that might or might not be considered backward compatible. This generally includes deeper changes such as performance optimizations or other general subtle functionality changes that are neither bug fixing nor necessarily new functionality but are still modifications of the underlying system that responds to API requests and result in different results in some way or another.
一个简单的例子是性能优化(或回归),它使 API 调用返回结果的速度比以前更快(或更慢)。这种类型的更改意味着 API 响应是完全相同的,但它会在 API 服务器上经过不同的计算时间后显示出来。这通常被认为是向后兼容的更改;然而,可以从另一个方向进行论证。
One simple example of this is a performance optimization (or regression) that makes an API call return a result faster (or slower) than it did previously. This type of change would mean that the API response is completely identical, but it would show up after a different amount of computing time on the API server. This is often considered a backward compatible change; however, the argument could be made in the other direction.
例如,如果 API 调用当前需要大约 100 毫秒才能完成,则很难注意到导致同一调用需要 50 毫秒(如果性能提高)或 150 毫秒(如果性能下降)的更改。另一方面,如果 API 调用突然需要 10 秒(10,000 毫秒)才能完成,这种时间差异足以证明编程范式的改变是合理的。换句话说,对于如此缓慢的 API 调用,您可能更愿意使用异步编程风格,以便您的进程可以在等待 API 响应的同时做其他事情。在这种情况下,您可能会认为这是一个向后不兼容的更改,或者甚至可能是引入了导致性能严重下降的错误。
For example, if an API call currently takes about 100 milliseconds to complete, it’s pretty difficult to notice a change that causes that same call to take 50 milliseconds (if performance improved) or 150 milliseconds (if performance degraded). On the other hand, if the API call suddenly takes 10 seconds (10,000 milliseconds) to complete, this difference in timing is enough to justify a change in programming paradigm. In other words, with an API call that slow, you might prefer to use an asynchronous programming style so that your process can do other things while waiting for the API response in the meantime. In cases like that, you might consider this a backward incompatible change, or potentially even a bug introduced that causes such a severe degradation in performance.
另一个更深层次变化的例子可能是,如果您依赖机器学习模型在 API 调用中生成一些结果,例如翻译文本、识别图像中的人脸或项目,或者标记视频中的场景变化。在此类情况下,更改所使用的机器学习模型可能会导致这些类型的 API 调用产生截然不同的结果。例如,有一天您可能有一张图片,API 调用说它包含一只狗的图片,而下一天它可能会说那实际上是一个松饼(这比您想象的更常见;尝试搜索“Chihuahua or Muffin”以看一个例子)。
Another example of a deeper change might be if you rely on a machine learning model to generate some results in an API call, such as translating text, recognizing faces or items in an image, or labeling scene changes in a video. In cases such as these, changing the machine learning model that’s used might lead to drastically different results for these types of API calls. For example, one day you might have an image that the API call says contains pictures of a dog, and the next it might say that’s actually a muffin (this is more common than you might think; try searching for “Chihuahua or Muffin” to see an example).
考虑到不同的结果,这种更改可能对一个 API 用户来说是一种改进,对另一个用户来说可能是一种回归。而且,根据 API 用户的情况,可能需要从机器学习模型中获得稳定的结果。换句话说,一些用户可能真的依赖提供一致结果的图像,除非他们明确要求进行某种升级。在这种情况下,这种类型的改变实际上可能被认为是落后的 不相容。
This kind of change might be an improvement to one API user and a regression to another given the different results. And, depending on the profile of API users, there may be a need for stable results from a machine learning model. In other words, some users might really rely on an image providing consistent results unless they explicitly ask for an upgrade of sorts. In cases like these, this type of change might actually be considered backward incompatible.
这 最后要考虑的类别可能是最广泛和最微妙的:一般语义变化。这指的是一般行为变化或 API 中各种概念的含义(例如,资源或字段)。
The last category to consider is probably both the broadest and most subtle: general semantic changes. This refers to general behavioral changes or the meaning of the various concepts in an API (e.g., resources or fields).
这些类型的变化涵盖了广泛的可能性。例如,这可能是大而明显的事情(例如在 API 中引入新的权限模型导致的行为变化),或者它可能是更微妙和难以注意到的事情(例如返回项目的顺序)在数组字段中列出资源或项目时)。正如您可能猜到的那样,没有用于处理这些类型的更改的一揽子最佳实践策略。相反,我们能做的最好的事情就是探索它对现有用户及其代码的影响,考虑这些用户可能对不稳定的接受程度,然后确定更改是否具有足够的破坏性以证明将更改注入现有版本或部署一个新的、孤立的。
These types of changes cover a broad range of possibilities. For example, this might be something large and obvious (such as the behavioral changes resulting from the introduction of a new permission model in an API), or it might be something much more subtle and difficult to notice (such as the order of items returned when listing resources or items in an array field). As you might guess, there is no blanket best practice policy for handling these types of changes. Instead, the best we can do is explore the effect it would have on existing users and their code, consider how accepting of instability those users might be, and then decide whether the change is disruptive enough to justify injecting the change into an existing version or deploying a new, isolated one.
要了解这意味着什么,让我们想象一个示例聊天 APIChatRoom和User资源,用户可以在聊天室内创建消息。如果我们决定要引入消息策略的新概念,该消息策略根据各种因素(例如用户发送消息的速率)确定用户是否能够在聊天室内创建消息(例如,您不能每秒发布一次以上)甚至是消息的内容(也许机器学习算法会确定一条消息是否是辱骂性的并阻止它)。引入此资源的更改是否向后兼容?正如您可能猜到的那样,答案还是“也许”。
To see what this means, let’s imagine an example Chat API with ChatRoom and User resources, where users can create messages inside chat rooms. What if we decide we want to introduce a new concept of a message policy that determines whether users are able to create messages inside chat rooms based on a variety of factors such as the rate at which users are sending messages (e.g., you can’t post more than once per second) or even the content of the messages (maybe a machine learning algorithm determines whether a message is abusive and blocks it). Would a change introducing this resource be backward compatible? As you might guess, the answer is, yet again, “maybe.”
首先,该决定可能取决于现有ChatRoom资源的默认语义(例如,现有ChatRoom资源是否会自动采用这种新行为?或者这是否仅保留给新创建的ChatRooms?)。无论如何,我们必须看看对现有用户的影响。新消息策略概念是否会导致现有代码中断?这也取决于。如果某人有一个脚本试图快速连续发送两条消息,那么现有代码可能会由于每秒仅允许一条消息的限制而停止正常运行。
First, the decision might depend on the default semantics of existing ChatRoom resources (e.g., do existing ChatRoom resources automatically pick up this new behavior? Or is this only reserved for newly created ChatRooms?). Regardless, we have to look at the impact to existing users. Does the new message policy concept cause existing code to break? This also depends. If someone has a script that attempts to send two messages in quick succession, that existing code might stop functioning correctly due to the restriction of only allowing a single message per second.
Listing 24.3 Code that may fail after new semantic changes introduced
function testMessageSending() { const chatRoom = ChatRoom.get(1234); chatRoom.sendMessage("Hello!"); ❶ chatRoom.sendMessage("How is everyone?"); ❷ }
❶ This sendMessage() call will succeed as usual.
❷如果实施新的速率限制,此 sendMessage() 调用可能会失败。
❷ This sendMessage() call might fail if new rate limiting is enforced.
但这种情况如何发生?用户是否在他们以前获得成功的时候得到了错误响应?还是消息只是进入队列并在一秒钟后显示?这些都不一定是对的,但肯定是不一样的。首先,用户会立即看到一个错误,这显然会让他们认为更改导致了他们现有代码的崩溃。在后者中,错误可能直到稍后才被注意到(例如,如果他们快速发送两条消息然后验证它们都已收到,验证步骤将失败,因为消息已延迟整整一秒)。
But how does that scenario play out? Does the user get an error response when they previously would have gotten a successful one? Or does the message simply go into a queue and show up one second later? Neither of these is necessarily right, but they are certainly different. In the first, the user will see an error immediately, which would clearly cause them to think the change has caused their existing code to break. In the latter, the error might not be noticed until later down the line (e.g., if they send two messages quickly and then verify they have both been received, the verification step will fail because the message has been delayed for a full second).
Listing 24.4 Code that may fail in a different place after changes
function testMessageSending() { const chatRoom = ChatRoom.get(1234); chatRoom.sendMessage("Hello!"); ❶ chatRoom.sendMessage("How is everyone?"); ❶ const messages = chatRoom.listMessages(); if (messages.length != 2) { ❷ throw new Error("Test failed!"); } }
❶ These two sendMessage() calls will succeed as usual.
❷除非在发送和检查消息结果之间有完整的一秒钟暂停,否则对正在发布的消息的检查将失败。
❷ This check for the messages being posted will fail unless there is a full one-second pause between the sending and the check for the message results.
正如我们所知,最终决定此更改是否向后兼容取决于现有用户的期望。几乎总是会有某个用户在某个地方拥有能够证明 API 更改正在中断的代码。由您决定用户是否可以合理地期望在 API 的特定版本的生命周期内支持特定的代码位。基于此,您可以确定您的语义更改是否被认为是向后兼容的。
As we’ve learned, ultimately the decision of whether this change is backward compatible or not is dependent on the expectations of the existing users. Almost always there will be a user somewhere who will have code that manages to demonstrate that an API change is breaking. It’s up to you to decide whether a user could reasonably expect that particular bit of code would be supported over the lifetime of a specific version of an API. Based on that, you can determine whether your semantic change is considered backward compatible.
既然我们至少对在决定向后兼容策略时需要考虑的不同问题有了某种了解,我们需要继续研究决定版本控制策略的一些考虑因素。为此,我们需要了解将要进行的各种权衡 进入 我们的决定。
Now that we have at least some sort of grasp on the different issues that we’ll need to consider when deciding on a backward compatibility policy, we need to move on and look at some of the considerations that go into deciding on a versioning strategy. To do this, we need to understand the various trade-offs that will go into our decision.
假设如果您已经就哪些更改考虑向后兼容做出了一些选择,那么您在决定如何管理版本控制的过程中才刚刚完成一半。事实上,您已经完成的唯一情况是您决定绝对所有内容都将向后兼容,因为您永远只需要一个版本。但在所有其他情况下,我们将需要探索和了解有关如何在您碰巧需要新版本时处理新版本的许多不同选项。换句话说,如果您进行了您认为向后不兼容的更改并打算创建一个新版本,那么它究竟是如何工作的呢?例如,我们应该使用什么作为新版本的名称?理想情况下,有某种模式可以让用户更容易理解。这些版本在被弃用之前应该存在多长时间?一个可能的答案是永远,但对于所有其他选择,您需要一个弃用政策来向用户传达他们应该期望旧版本消失并停止工作的时间。
Assuming you’ve made some choices about what changes you’ll consider backward compatible, you’re only about halfway through the journey of deciding how to manage versioning. In fact, the only case where you’re already done is if you decide that absolutely everything will be backward compatible because you’ll only ever need one single version forever. In all the other cases though, we’ll need to explore and understand the many different options available on how to handle the new versions when you do happen to need them. In other words, if you make a change you consider backward incompatible and intend to create a new version, how exactly does that work? For example, what should we use as a name for the new versions? Ideally, there’s some sort of pattern to make it easy for users to understand. How long should these versions live before being deprecated? One possible answer for this is forever, but for every other choice, you’ll need a deprecation policy to convey to users when they should expect old versions to disappear and stop working.
考虑到这些类型的问题,让我们花点时间探索一些流行的版本控制策略。在这些文章中,我们将解释它们如何工作、它们的优点和缺点,以及它们如何根据我们在第 24.2 节中考虑的权衡进行比较。请记住,这些策略可能暗示或暗示了一组用于确定向后兼容性的策略,但它们并不总是规定用于做出此决定的单一策略。换句话说,在选择一些关于是否考虑向后兼容的更改的策略时,每个策略都应该具有一定的灵活性。
With these types of issues in mind, let’s take a moment to explore a few popular versioning strategies. In these, we’ll explain how they work, their benefits and drawbacks, and how they compare to one another based on the trade-offs we considered in section 24.2. Keep in mind that these strategies might hint at or imply a set of policies for determining backward compatibility, but they don’t always prescribe a single policy for making this determination. In other words, each strategy should have some flexibility when it comes to choosing some policies on whether to consider a change backward compatible.
一最流行的版本控制策略之一恰好是许多人经常不小心使用的策略。很多人都不愿意承认,许多 API 在创建时根本没有考虑版本控制方案或策略。只有当需要进行非常大、可怕且(最重要的)向后不兼容的更改时,我们才开始考虑版本控制。在这种情况下,我们的下一步是决定将现有的所有内容变为“v1”,并将具有新更改的 API 称为“v2”。在这一点上,我们通常可能会开始考虑还有什么可以适合 v2。例如,也许我们一段时间以来一直想更改的其他字段可以在 v2 中得到修复。
One of the most popular versioning strategies happens to be the one many people often end up using accidentally. Far more than anyone would care to admit, many APIs are created with no versioning scheme or strategy in mind at all. It’s only when the need arises to make a very big, scary, and (most importantly) backward incompatible change that we start to think about versioning. In that scenario, our next step is to decide that everything as it exists today will become “v1” and the API with the new changes will be called “v2.” At this point, we often might start considering what else can fit into v2. For example, maybe that other field we’ve been wanting to change for a while can be fixed in v2.
这种策略通常被称为永久稳定,主要是因为每个版本通常永远保持稳定,所有新的可能向后不兼容的更改都保留给下一个版本。尽管这种策略往往是偶然发生的,但没有什么能阻止我们有意地使用相同的策略。在那种情况下,它会如何运作?
This strategy is often referred to as perpetual stability primarily because each version is typically left stable forever, with all new potentially backward incompatible changes being reserved for the next version. Even though this strategy tends to come about accidentally, there is nothing preventing us from using the same strategy in an intentional way. In that case, how might it work?
The general process we follow with this strategy is as follows:
All existing functionality is labeled “Version N” (e.g., the first version is Version 1”).
Any changes that are backward compatible are added directly into Version N.
任何可能与我们的兼容性定义相冲突的新功能都作为“版本 N+1”的一部分构建(例如,任何与版本 1 向后不兼容的内容都保存在“版本 2”中)。
Any new functionality that might fall afoul of our compatibility definitions is built as part of “Version N+1” (e.g., anything that is backward incompatible for Version 1 is saved for “Version 2”).
Once we have enough functionality to merit a new version, we release Version N+1.
Optionally, at some point in the future, we might deprecate Version N to avoid incurring extra maintenance costs or because no one appears to be using the version anymore.
At this point, we go back to step 1 and the cycle begins again.
这个过程在一些场景中运作良好,权衡如图 24.2 所示。首先,在你没有引入很多向后兼容的变化的情况下,大部分变化都可以滚入现有版本,而不会给用户带来太多麻烦,并且让大多数变化愉快地停留在第 2 步。假设兼容性策略保持相当高禁止向后兼容,这可能会导致非常稳定的 API,以换取快速部署许多新功能。
This process works well in a few scenarios, with the trade-offs shown in figure 24.2. First, in cases where you don’t introduce many backward compatible changes, the majority of changes can be rolled into the existing version without causing much trouble for users and leaving most changes happily stuck on step 2. Assuming the compatibility policy maintains a reasonably high bar of what is backward compatible, this will likely lead to a very stable API in exchange for deploying lots of new functionality rapidly.
Figure 24.2 Trade-offs for perpetual stability versioning strategy
此外,再次假设向后兼容更改的标准相当高,该策略倾向于根据版本控制策略最大限度地增加可以合理使用 API 的人数。它可能并不完美,但大多数用户可能都在发行版的“好”桶中。
Additionally, and again assuming a reasonably high bar for what passes as a backward compatible change, this strategy tends to maximize the number of people who can reasonably use the API based on the versioning strategy. It might not be perfect, but the majority of users are likely to be in the “okay” bucket of the distribution.
在用户绝对需要极端级别的粒度的情况下,此策略不太可能有效。例如,作为使用此版本控制策略的 API 客户端的 IoT 设备可能会遇到困难,因为它鼓励将大量更改滚动到现有版本中,而 IoT 设备通常需要能够将 API 冻结在一个确切的版本以避免一些棘手的边缘情况,例如随着内存溢出。
This strategy is unlikely to work well in cases where users absolutely require an extreme level of granularity. For example, an IoT device as an API client using this versioning strategy would probably struggle because it encourages lots of changes rolling into the existing version whereas IoT devices typically need the ability to freeze an API at an exact version to avoid some tricky edge cases such as memory overflows.
如果向后兼容性的门槛过高(即,所有更改都被认为是不兼容的),您可能最终会进入一个拥有大量版本(v1、v2、...、v100 等)的世界。显然,这对于管理所有这些版本的服务和决定哪个版本适合他们使用的客户来说都变得笨拙。
In cases where the bar for backward compatibility is excessively high (i.e., all changes are considered incompatible), you may end up in a world with an exceeding large number of versions (v1, v2, . . ., v100, and on). Obviously, this can become unwieldy for both the service managing all of those versions and clients deciding which version is right for them to use.
不管这些问题如何,许多流行的 API 都依赖于这种版本控制机制,并且多年来一直运行良好。例如,许多 Google Cloud Platform API 出于各种原因使用此策略,虽然它肯定不完美,但它似乎对许多人来说工作得很好顾客。
Regardless of these issues, many popular APIs rely on this versioning mechanism and have made it work well over the years. For example, many Google Cloud Platform APIs use this strategy for a variety of reasons, and while it certainly isn’t perfect, it does seem to work quite well for many customers.
另一种流行的策略,有时称为敏捷不稳定性,它依赖于活动版本的滑动窗口,以最大限度地减少 API 设计人员的维护开销,同时仍能及时为客户提供新功能。虽然远非完美,但对于活跃和参与的 API 用户来说,这种策略可能非常有效,这些用户充分参与产品开发,以应对以前版本的频繁弃用,以换取所提供的新功能。
Another popular strategy, sometimes referred to as agile instability, relies on a sliding window of active versions in order to minimize the maintenance overhead for API designers while still providing new functionality in a timely manner to customers. While far from perfect, this strategy can be quite effective with active and engaged API users who are involved enough in product development to cope with frequent deprecations of previous versions in exchange for the new functionality provided.
该策略的工作原理是推动每个版本经历一个稳定的生命周期,从出生(预览)开始,所有功能都可能随时更改,一直到死亡(删除),版本本身不再可用。表 24.1 中显示了不同状态的概述,但要了解其工作原理,让我们通过演示 API 中的几个版本的示例场景。
This strategy works by pushing each version through a steady life cycle, going from birth (preview), where all functionality might change at any time, all the way to death (deleted), where the version itself is no longer accessible. An overview of the different states is shown in table 24.1, but to see how this works, let’s go through an example scenario of a few versions in a demo API.
Table 24.1 Overview of different version states
刚开始时,API 肯定不够稳定,无法向任何人展示。最重要的是,开发人员希望自由地随时更改它。在这种情况下,将所有工作放入版本 1 是最简单的方法。在这种情况下,我们将版本 1 标记为“预览版”,因为它还没有为真正的用户准备好。任何接受此阶段没有稳定性保证这一事实的用户都可以免费使用它,但他们应该确定他们理解这里的残酷事实:他们今天编写的代码明天可能会被破坏。
When first starting out, an API is certainly not stable enough to show anyone. Most importantly, developers want the freedom to change it at the drop of a hat. In this scenario, it’s simply easiest to put all work into version 1. In this case, we’d label version 1 as “Preview” as it’s not really ready for real users. Any users who are accepting of the fact that there are no stability guarantees for this stage are free to use it, but they should be certain they understand the hard truths here: code they write today will probably be broken tomorrow.
一旦版本 1 看起来更加成熟,我们可能希望允许真实用户开始针对此 API 进行构建。在这种情况下,我们将版本 1 提升为“当前”。在这个阶段,我们希望确保客户端代码继续工作,因此唯一应该进行的更改是强制性要求(例如,安全补丁)和潜在的关键错误修复。简而言之,通过将版本 1 提升为当前版本,我们将完全按原样冻结它,除非绝对必要,否则不要管它。
Once version 1 is looking more mature, we might want to allow real users to start building against this API. In this case, we promote version 1 to “Current.” While in this stage, we want to ensure that client code continues working, so the only changes that should be made are mandatory requirements (e.g., security patches) and potentially critical bug fixes. In short, by promoting version 1 to current, we’re freezing it exactly as it is and leaving it alone unless absolutely necessary.
不过,显然功能开发不应该简单地停止。那么我们把所有新的努力工作放在哪里呢?简单的答案是,我们希望对版本 1 进行的任何新功能或其他非关键更改都将简单地放入下一个版本:版本 2。而且恰好版本 2 被标记为新的预览版本. 正如您所料,版本 2 的规则与我们在预览阶段对版本 1 使用的规则相同。
Obviously feature development shouldn’t simply stop, though. So where do we put all the new hard work? The simple answer is that any new features or other noncritical changes that we would’ve liked to make to version 1 would simply be put into the next version: version 2. And it just so happens that version 2 is labeled as the new preview version. As you might expect, the rules for version 2 are the same rules we used to have for version 1 when it was in the preview stage.
在某个时候,这个循环会重复,版本 2 会变得更加成熟,以至于我们希望将其提升为新的当前版本。当这种情况发生时,我们遇到了一个新问题:我们如何处理现有的当前版本?我们不应该维护两个当前版本,但我们也不希望仅仅因为有更新更好的东西可供用户使用而完全删除一个版本。在此策略中,我们的答案是将此版本标记为“已弃用”。在弃用状态下,我们将执行与当前版本时相同的更改规则;然而,我们开始为版本最终消失的时间计时——毕竟,我们不想维护所有曾经存在的版本,直到时间结束!
At some point this cycle will repeat and version 2 will become more mature to the point that we’ll want it to be promoted to become the new current version. When that happens, we have a new problem: what do we do with the existing current version? We shouldn’t maintain two current versions, but we also don’t want to delete a version entirely just because there’s something newer and shinier available to users. In this strategy, our answer is to mark this version as “Deprecated.” While in the deprecated state, we’ll enforce the same rules about changes as we did when the version was current; however, we start a ticking clock for when the version will ultimately go away—after all, we don’t want to maintain every version that ever existed until the end of time!
一旦时钟计时器到期,我们将完全删除有问题的已弃用版本,它可以被视为已删除。一个版本从弃用到删除的确切时间取决于 API 用户的期望;然而,最重要的是提前声明一些具体的时间,与 API 的用户共享,并遵守时间表。表 24.2 中显示了此时间表的摘要以及每月发布节奏(以及两个月的弃用政策)。
Once the clock timer expires, we remove the deprecated version in question entirely and it can be considered deleted. The exact timing of how long it takes for a version to go from deprecated to deleted depends on API users’ expectations; however, the most important thing is to have some specific amount of time declared ahead of time, shared with the users of the API, and stick to the timeline. A summary of this timeline is shown in table 24.2 with a monthly release cadence (and a two-month deprecation policy).
Table 24.2 Example timeline of versions and their states
这个策略有很多有趣的地方,图 24.3 显示了不同权衡的总结。首先,请注意虽然可能有许多不同的弃用(和删除)版本,但只有一个当前版本和一个预览版本。这是该策略的一个关键部分,因为它作为一种强制功能,在将活动版本的数量保持在绝对最小的同时,朝着持续改进的方向发展。换句话说,这种策略倾向于将新版本视为现有版本之上的改进,而不是恰好同样好的替代视图。通过弃用版本以便为最新最好的新版本让路,我们确保新功能不会花太长时间出现在 API 用户面前。
There are quite a few interesting things about this strategy, with a summary of the different trade-offs shown in figure 24.3. First, note that while there may be lots of different deprecated (and deleted) versions, there is only ever a single current version and a single preview version. This is a critical piece of the strategy in that it acts as a forcing function to move toward continual improvement while keeping the number of active versions to an absolute minimum. In other words, this strategy tends to see new versions as improvements on top of the existing versions rather than alternative views that happen to be equally good. By deprecating versions in order to make way for the latest and greatest new version, we ensure that new functionality doesn’t take too long to get in front of API users.
Figure 24.3 Trade-offs for agile instability versioning strategy
此外,请注意,快速循环版本的好处也可能是一个缺点:针对当前版本编写的任何代码实际上最终都会停止工作。至少,绝对不能保证它会继续发挥作用,所以如果确实如此,那可能纯属巧合。这意味着任何希望能够编写代码然后在几年内忘记它的用户可能会发现此版本控制策略完全无法使用。另一方面,任何积极更新和更改代码并且非常欣赏新功能的人都会发现这种策略非常受欢迎,因为他们对软件开发持态度。
Additionally, notice that this benefit of rapid cycling through versions can also be a drawback: any code written against a current version is virtually guaranteed to stop working eventually. At the very least, there is absolutely no guarantee that it will continue to function, so if it does that might be a pure coincidence. This means that any users who expect to be able to write code and then forget about it for a couple of years will likely find this versioning strategy completely unusable. On the other hand, anyone who is actively updating and changing their code and has a great appreciation for new functionality will find this strategy quite welcoming given their attitude toward software development.
总的来说,当用户活跃并参与开发并且需要小的稳定性窗口然后快速升级到具有新功能的新版本时,此策略可以很好地工作。任何需要长期稳定性以换取随着时间的推移对新功能的访问越来越少的用户几乎肯定会发现此模型非常难以使用。虽然有可能巧合地让代码继续工作,但缺乏保证可能会让任何人非常反感,他们更愿意有一个更有力的承诺,即他们的代码将继续按预期运行,而不是在固定的、相对较短的时间内的窗口时间。
Overall, this strategy can work well when users are active and engaged in development and need small windows of stability followed by quick upgrades to new versions with new functionality. Any users who require longer-term stability in exchange for having less access to new functionality over time will almost certainly find this model very difficult to use. And while it’s possible to coincidentally have code continue to work, the lack of a guarantee can be quite off-putting to anyone who would much rather have a stronger promise that their code will continue to function as intended for more than a fixed, relatively short window of time.
大概当今最流行的版本控制形式,语义版本控制(或 SemVer;https: //semver.org )是一个非常强大的工具,它允许单个版本标签用三个简单的数字传达相当多的含义。最重要的是,这个含义是实用的:它可以告诉用户两个 API 之间的区别,以及针对一个 API 编写的代码是否应该继续与另一个 API 一起工作。让我们花点时间看看它是如何工作的。
Probably the most popular form of versioning out there today, semantic versioning (or SemVer; https://semver.org), is a very powerful tool that allows a single version label to convey quite a lot of meaning in three simple numbers. Most importantly, this meaning is practical: it can tell a user about the differences between two APIs and whether code written against one should continue to work with another. Let’s take a moment to look at how exactly this works.
语义版本字符串(如图 24.4 所示)由三个由点分隔的数字组成(例如,1.0.0 或 12.5.2),其中每个数字随着对 API 的更改而增加,并且对更改具有不同的含义这是为了实现这一增长。版本字符串的第一个数字,称为主要版本,在根据我们在第 24.2.2 节中探讨的定义的兼容性策略,更改被认为向后不兼容的情况下会增加。例如,更改字段名称几乎总是被认为是向后不兼容的更改,因此如果我们要重命名字段,版本字符串将增加主要版本(例如,从1 .0.0 到2 .0.0)。换句话说,您可以假设为主要版本 N 编写的任何代码几乎肯定不会像对主要版本 M 的预期那样运行。如果它碰巧实际运行,那完全是巧合,根本不是由于任何原因通过设计保证。
A semantic version string (illustrated in figure 24.4) is built up of three numbers separated by dots (e.g., 1.0.0 or 12.5.2), where each number increases as changes are made to an API and carries a different meaning about the change that was made to bring about this increase. The first number of the version string, called the major version, is increased in scenarios where the change is considered backward incompatible according to the compatibility policy defined, which we explored in section 24.2.2. For example, changing a field name is almost always considered a backward incompatible change, so if we were to rename a field, the version string would increment the major version (e.g., from 1.0.0 to 2.0.0). In other words, you can assume that any code written for a major version N is almost certainly not going to function as expected for a major version M. If it happens to actually work, it’s entirely coincidental and not at all due to any sort of guarantee by design.
Figure 24.4 The different version numbers in a semantic version string
语义版本字符串中的下一个数字是次要版本. 当根据兼容性策略中定义的某些新功能或行为更改的规则进行向后兼容更改时,此数字会增加。例如,如果我们要向 API 添加一个新字段并且我们已将此操作定义为在我们的策略中向后兼容,那么我们将增加次要版本而不是主要版本(例如,从 1. 0 .0到 1. 1.0)。这很强大,因为它允许您假设为特定次要版本编写的任何代码在针对未来次要版本时保证不会被破坏,因为每个后续次要版本只进行向后兼容的更改(例如,为版本 1.0 编写的代码.0 在针对 1.1.0 和 1.2.0 运行时不会崩溃)。虽然这个较旧的代码将无法利用较新的次要版本中提供的新功能,但在针对任何较新的次要版本运行时,它仍将按预期运行。
The next number in a semantic version string is the minor version. This number is incremented when a backward compatible change is made according to the rules defined in the compatibility policy that is some new functionality or change in behavior. For example, if we were to add a new field to the API and we’ve defined this action as being backward compatible in our policy, then we would increment the minor version rather than the major version (e.g., from 1.0.0 to 1.1.0). This is powerful because it allows you to assume that any code written for a specific minor version is guaranteed not to be broken when targeted at future minor versions, as each subsequent minor version is only making backward compatible changes (e.g., code written for version 1.0.0 will not crash when run against both 1.1.0 and 1.2.0). While this older code will not be able to take advantage of the new functionality provided in the newer minor versions, it will still function as expected when run against any of the newer minor versions.
语义版本字符串中的第三个也是最后一个数字是补丁版本。当进行向后兼容且主要是错误修复而不是添加新功能或更改行为的更改时,此数字会增加。理想情况下,该数字不应传达任何关于兼容性的明显暗示。换句话说,如果两个版本之间的唯一区别是补丁版本,那么针对一个版本编写的代码应该可以与另一个版本一起使用。
The third and final number in a semantic version string is the patch version. This number is incremented when a change is made that is both backward compatible and primarily a bug fix rather than new functionality being added or behavior being altered. This number should, ideally, convey no obvious implication regarding compatibility. In other words, if the only difference between two versions is the patch version, code written against one should work with the other.
当与 Web API 一起使用时,语义版本控制只需在新更改出现时遵循这些规则。进行向后不兼容的更改?该更改应作为下一个主要版本发布。向 API 添加一些向后兼容的功能?保持相同的主要版本,但增加次要版本。应用向后兼容的错误修复?保持相同的主要和次要版本,并使用递增的补丁版本发布补丁代码。正如我们在 24.2.2 节中探讨的那样,您的兼容性策略将指导您决定您的更改属于哪个类别;所以只要你遵循它,语义版本控制就可以很好地工作。
Semantic versioning, when used with web APIs, simply follows these rules as new changes come out. Making a change that would be backward incompatible? That change should be released as the next major version. Adding some backward compatible functionality to the API? Keep the same major version but increment the minor version. Applying a backward compatible bug fix? Keep the same major and minor version and release the patched code with an incremented patch version. As we explored in section 24.2.2, your compatibility policy will guide you to decide which category your change falls into; so as long as you follow it, semantic versioning works quite well.
然而,语义版本控制的一个主要问题是可供用户使用的版本数量过多(并作为单独的服务进行管理)。换句话说,语义版本控制提供了大量可供选择的版本,每个版本都具有不同的功能集,以至于用户最终可能会非常困惑。
One major issue with semantic versioning, though, is the sheer number of versions available to the user (and to be managed as separate services). In other words, semantic versioning provides a huge number of versions to choose from, each with a varying set of functionality, to the point where users might end up quite confused.
此外,由于用户希望能够尽可能长时间地固定到一个特定的、非常精细的版本,这意味着 Web API 实际上必须维护和运行许多不同版本的 API,这可能成为基础设施方面的一个难题。幸运的是,由于版本在提供给用户后几乎完全冻结,我们通常可以冻结并继续运行二进制文件,但开销仍然非常大。也就是说,现代基础架构编排和管理系统(例如 Kubernetes;https ://kubernetes.io/ )和现代开发范式(例如微服务)可以帮助减轻这个问题带来的大部分痛苦,因此依赖这些工具可以提供很多帮助。
Additionally, since users will want to be able to pin to a specific, very granular version for as long as possible, this means that the web API must actually maintain and run lots of different versions of the API, which can become an infrastructural headache. Luckily, since versions are almost completely frozen after they are made available to users we can often freeze and continue to run a binary, but the overhead can still be quite daunting. That said, modern infrastructure orchestration and management systems (e.g., Kubernetes; https://kubernetes.io/) and modern development paradigms (e.g., microservices) can help to alleviate much of the pain brought about by this problem, so relying on these tools can help quite a bit.
也就是说,并不一定要求版本永远存在。相反,定义弃用策略并说明从版本可用时起预计将维护和继续运行多长时间并没有错。一旦该时钟用完,该版本将从服务中删除并消失。
That said, it’s not necessarily required that versions always live forever. On the contrary, there’s nothing wrong with defining a deprecation policy and stating from the time a version is made available how long it’s expected to be maintained and continue running. Once that clock runs out, the version is removed from service and simply disappears.
除了这个弃用政策,依赖语义版本控制的最好的事情之一是稳定性和新功能之间的平衡,有效地让我们两全其美。从某种意义上说,用户能够固定到特定版本(一直到修复了哪个错误),同时在需要时可以访问新功能。有一个明显的问题是可能很难选择特定的功能(例如,有人可能想使用 1.0.0 版但可以访问 2.4.0 版中添加的一些特殊功能),但这只是一个问题与任何基于时间顺序的版本控制策略。
This deprecation policy aside, one of the best things about relying on semantic versioning is the balance between stability and new functionality, effectively giving us the best of both worlds. In a sense, users are able to pin to specific versions (all the way down to which bug was fixed) while at the same time having access to new functionality if needed. There is the clear issue that it might be hard to pick and choose specific features (e.g., someone might want to use version 1.0.0 but get access to some special functionality that was added in version 2.4.0), but that’s simply an issue with any chronologically based versioning strategy.
最后,这个策略还在让每个人都满意和让相当多的人开心之间找到了平衡(如图 24.5 所示)。简而言之,因为有太多的选择,并且假设有一个合理的弃用政策,大多数用例,从想要快速探索新功能的小型初创公司到想要稳定性的大型企业客户,都能够得到他们想要的东西想要退出这个版本控制方案。最重要的是,这不会以让用户感到沮丧的策略为代价。可用版本选项的数量可能有点多,但总的来说,这种策略允许用户从 API 中获得他们想要的东西,假设它已经构建并放入特定版本。
Lastly, this strategy also finds a balance between being satisfactory to everyone and making quite a few people happy (summarized in figure 24.5). In short, because there is so much choice available, and assuming a reasonable deprecation policy, most use cases, ranging from the small startups who want the ability to quickly explore new functionality to large corporate customers who want stability, are able to get what they want out of this versioning scheme. Most importantly, this doesn’t come at the cost of a strategy that frustrates users. It might be a bit overwhelming in the number of version options available, but overall this strategy allows users to get what they want from an API, assuming it’s been built and put into a specific version.
Figure 24.5 Trade-offs for semantic versioning strategy
请记住,这些只是对 Web API 进行版本控制的一些流行策略,并不是一个详尽的列表。如您所见,每种方法都有自己的优点和缺点,因此不能保证任何一种方法都适合所有特定场景。因此,了解所有可用的不同选项并仔细考虑哪个选项可能是您的 Web API 的最佳选择非常重要,因为您的独特集的约束、要求和偏好的用户。
Keep in mind that these are just a few popular strategies for versioning web APIs and not at all an exhaustive list. As you’ve seen, each has their own benefits and drawbacks, and therefore none is guaranteed to be a perfect fit for all specific scenarios. As a result, it’s important to understand all the different options available and think carefully about which option is likely to be the best choice for your web API given the constraints, requirements, and preferences of your unique set of users.
作为我们可以看到,定义关于哪些类型的更改应被视为向后兼容的策略是复杂的。但更重要的是,我们在这些主题上做出的选择几乎肯定是我们在频谱的两个不同端点之间取得平衡的方式。虽然通常在这些主题上从来没有完美的选择,但我们至少可以了解我们正在做出的权衡,并确保这些选择是有意识和有意做出的。让我们花点时间了解在为每种独特情况决定兼容性策略时应该考虑的各种频谱。
As we can see, defining a policy on what types of changes should be considered backward compatible is complicated. But more importantly, the choices we make on these topics will almost certainly be our way of striking a balance between two different ends of a spectrum. While there is typically never a perfect choice on these topics, the least we can do is understand the trade-offs we’re making and ensure that these choices are made consciously and intentionally. Let’s take a moment to understand the various spectrums we should consider when deciding on the compatibility policy for each unique situation.
这首先,我们的许多选择都基于非常广泛的范围,这是一个选择。在《选择的悖论》(Harper Perennial,2004 年)一书中,Barry Schwartz 讨论了消费品的更多选择并不总能带来更快乐的购买者。相反,过多的选择实际上会导致焦虑程度增加。在设计 API 时,我们并不是真的在购物中心购买产品;然而,这个论点可能仍然有一定的分量,值得研究。在选择版本控制策略时,这种权衡必须在大量粒度和选择与更简单、一刀切的单一选项之间取得平衡。
The first, very broad, spectrum that many of our choices will lie on is one of choice. In the book The Paradox of Choice (Harper Perennial, 2004), Barry Schwartz discusses how more choice for consumer products doesn’t always lead to happier buyers. Instead, the overwhelming number of choices can actually cause increased levels of anxiety. When designing an API, we’re not really buying a product at a shopping mall; however, the argument may still carry some weight and is worth looking at. When it comes to choosing a versioning policy, this trade-off has to do with striking a balance between lots of granularity and choice versus a simpler, one-size-fits-all single option.
要了解这是如何工作的,请考虑这样一种情况:我们的客户非常关心稳定性,以至于任何更改都应被视为向后不兼容。结果是每个更改都将作为一个全新的版本进行部署,API 的用户将能够从潜在的大量版本中为他们的应用程序进行选择。毫无疑问,这种广泛的潜在版本集合确实确保给定用户可以选择一个非常具体的版本并坚持使用它,但它提出了一个问题,即该用户是否会因可用选项的数量而不知所措或感到困惑。
To see how this works, consider the case where we decide that our customers care about stability so much that any change at all should be considered backward incompatible. The result is that each and every change will be deployed as an entirely new version, and users of the API will have the ability to choose from a potentially enormous number of versions for their application. It’s certainly true that this extensive collection of potential versions does ensure that a given user can choose a very specific version and stick to it, but it raises the question of whether that user will be overwhelmed or confused by the sheer number of options available.
此外,以这种方式运行的版本控制方案也可能会倾向于遵循时间流,从而导致随着时间的推移稳定的变化流。这意味着用户无法挑选和选择 API 的特定方面,而只能获得某种时间机器,他们可以在其中选择代表 API 如何看待历史上特定点的特定版本。如果该用户想要两年前的某些行为以及今天的某些功能,他们通常也将被迫接受这两点之间的所有行为变化。简而言之,固定到一个版本的成本是它必须包括在该特定版本发布之前所做的所有更改。
Further, it’s also likely that versioning schemes behaving this way will tend to follow a temporal flow, resulting in a steady stream of changes over time. This means that a user isn’t able to pick and choose specific aspects of an API but instead is only given a time machine of sorts where they can pick a specific version representing how the API looked at a specific point in history. If that user wants some behavior from two years ago as well as some feature from today, they’ll typically be forced to accept all changes in behavior between those two points as well. In short, the cost for pinning to a version is that it must include all changes made up until the launch of that specific version.
现在考虑范围的另一端:无论对现有 API 用户的影响如何,每次更改始终被认为是向后兼容的。在这种情况下,生成的 API 将永远只有一个版本,这意味着用户别无选择。虽然没有人会争辩说这种选择没有提供足够的选择,但它可能过于简单化了。换句话说,一个选择显然是最容易理解的事情,但考虑到 API 的典型受众,尤其是那些有特定稳定性要求的人,这很少是一个实用的选择。
Now consider the other end of the spectrum: every single change is always considered backward compatible no matter what the effect on existing users of the API happens to be. In this scenario, the resulting API would have only one single version, always and forever, meaning there is no choice at all for the users. While no one could ever argue that this choice doesn’t provide enough of a choice, it probably skews too far toward oversimplification. In other words, one choice is clearly the easiest thing to understand, but it’s rarely a practical choice given the typical audience of an API, particularly those who have specific stability requirements.
显然没有正确答案,但最佳答案几乎肯定在这两个极端之间。这可能意味着找到一组策略来确定哪些更改是向后兼容的,以便它们产生合理数量的版本(这里合理的定义肯定会有所不同),或者它可能意味着找到一个弃用策略,在其中删除旧版本时间点。无论哪种方式,权衡取舍都将取决于用户以及他们对选择的胃口有多大(以及您对管理所有这些不同的东西的胃口有多大)版本)。
Obviously there is no right answer, but the best answer is almost certainly somewhere in between these two ends of the spectrum. This might mean finding a set of policies for what changes are backward compatible such that they result in a reasonable number of versions (the definition of reasonable here will certainly vary), or it might mean finding a deprecation policy where you delete old versions at some point in time. Either way, the trade-off will depend on the users and how big their appetite for choices is (as well as how big yours is for managing all of these different versions).
这下一个值得讨论的权衡可能是最明显的:API 用户真正需要多少新功能?换句话说,如果您只是对 API 进行较少的更改,那么您甚至很少有机会决定更改是否向后兼容。这种权衡实际上更多的是以 API 用户对新功能、错误修复或行为更改的访问权限减少为代价,完全缩短兼容性策略。
The next trade-off worth discussion is probably the most obvious: how much new functionality do API users really need? In other words, if you simply make fewer changes to your API, then you have fewer chances to even decide whether a change is backward compatible. This trade-off is really more about short-circuiting the compatibility policy entirely at the cost of API users having less access to new features, bug fixes, or behavioral changes.
从这个角度来看,在这个范围的一端,我们拥有完美的稳定性:永远不要做任何改变,永远。这种情况意味着一旦 API 启动并可供用户使用,它就永远不会再改变。虽然这听起来很容易(毕竟,不管它有多难?)但实际上远比这复杂。要真正保持完全稳定,您还需要确保您从未应用任何安全补丁或升级为 API 提供支持的服务器的底层操作系统,并且您的任何供应商也是如此。如果这些事情中的任何一个发生了变化,一些微妙的东西就很容易进入用户的视野,从而在技术上导致变化。这意味着您需要决定它是否值得一个新版本。
To put this into perspective, on one end of this spectrum we have perfect stability: never make any changes, ever. This scenario means that once the API is launched and available to users, it will never change ever again. While this might sound easy (after all, how hard can it be to just leave it alone?) it’s actually far more complicated than that. To truly remain perfectly stable, you’d also need to ensure that you never applied any security patches or upgraded the underlying operating system for the servers that are powering your API, and that the same remains true of any of your suppliers. If any of those things change, it’s very easy for something subtle to make its way into the users’ view, resulting in, technically, a change. And that means you’d need to decide whether that merits a new version.
另一方面,您可能会决定启动任何和所有功能都至关重要。所有错误都非常重要,需要修复。操作系统和库升级、安全补丁和其他底层更改应尽快推送给 API 用户。在这种情况下,定义向后兼容性时所做的选择将一如既往地重要。
On the other end of the spectrum, you might decide that any and all functionality is critically important to launch. All bugs are critically important to fix. And operating system and library upgrades, security patches, and other underlying changes should be pushed out to API users as quickly as possible. In this scenario, the choices made when defining backward compatibility will be as important as ever.
像往常一样,对于您应该落在这个范围内的哪个位置没有最佳选择;然而,如果 API 无法选择最适合其用户的范围(而是即时弥补),则往往会激怒和挫败用户。而且,一如既往,最佳选择几乎肯定介于两者之间。对于不太通融的用户(例如,大型政府组织),对新功能的渴望可能不如稳定性重要。如果典型用户是初创公司,则情况可能恰恰相反。但重要的是要确定那些将使用 API 的人的期望,并决定这个范围内最适合这些人的期望用户。
As usual, there’s no best choice for where you should fall on this spectrum; however, APIs that don’t make the choice of where on the spectrum best suits their users (and instead just make it up on the fly) tend to anger and frustrate users far more. And, as always, the best choice almost certainly lies somewhere in between. For less accommodating users (e.g., a large government organization) the desire of new features might not be as important as stability. And the opposite might be true if the typical user is a startup. But it’s important to identify the expectations of those who will be using the API and decide where on this spectrum best suits those users.
这最后,可能也是最重要的,权衡取舍与您的 API 的各种用户如何接收您的策略有关。到目前为止,我们已经将用户视为一个单独的群体,他们对您的政策感到满意或不满意,您的政策可能被认为是向后兼容的。不幸的是,这种跨所有用户的同质性相对较少。换句话说,您的政策不太可能会受到每个客户的欢迎,原因很简单,因为用户彼此不同并且对我们正在决定的主题有不同的看法。虽然一些 API 可能偏向于同质的用户群体(例如,为中央银行或市政府构建的一组 API 可能都希望稳定性远胜于新功能),许多 API 吸引了不同的用户群体,我们不能再假设所有用户都属于同一个桶。我们做什么?
The final, and possibly most important, trade-off has to do with how your policy is received by the various users of your API. So far we’ve thought of users as one single group that will either be happy or not with your policies on which changes might be considered backward compatible. Unfortunately, this type of homogeneity across all of your users is relatively rare. In other words, it’s very unlikely that your policies will be well received by every single customer for the simple reason that users are different from one another and have different opinions on the topics we’re deciding on. While some APIs may skew toward homogeneous groups of users (e.g., a set of APIs built for central banks or municipal governments may all want stability far more than new functionality), many APIs attract a diverse group of users and we can no longer assume that all users fall into the same bucket. What do we do?
您可能会猜到,这是另一种权衡。要理解这一点,我们需要将 API 用户视为对我们的政策具有不同满意度的四组之一。范围从那些在特定情况下拒绝使用 API(不能使用)的人,到那些可以使用它但对它一点都不满意(疯狂)的人,再到那些对政策没问题但也不兴奋的人(okay),最后是对政策完全满意的人(happy)。虽然通常大多数用户应该落在好的桶中,但某些政策可能会疏远一大批潜在用户。例如,如果一个 API 有一个考虑所有更改向后兼容的策略,那么该策略可能会疏远很多人并迫使他们到别处寻找他们的 API 需求(例如,很多用户在不能使用的桶中)。
As you might guess, this is yet another trade-off. To understand this, we need to think of API users as falling into one of four groups with different levels of satisfaction with our policies. The spectrum goes from those who would refuse to use the API given the circumstances (cannot use), to those who can use it but are not at all happy with it (mad), to those who are fine with the policy but not thrilled either (okay), and finally those who are completely satisfied with the policy (happy). While typically the majority of users should land in the okay bucket, some policies may alienate a big group of potential users. For example, if an API had a policy of considering all changes backward compatible, that policy might alienate quite a few people and force them to look elsewhere for their API needs (e.g., lots of users in the cannot use bucket). Given this distribution, let’s look at the trade-off we’re considering.
一方面,我们为用户提供了最大的幸福感,我们的决定旨在通过我们的向后兼容性政策最大限度地增加快乐桶中的用户数量。虽然我们当然不能让所有用户都满意,但我们总是可以尝试。但是,请记住,这意味着我们选择的策略没有考虑其他存储桶。这可能会导致图 24.6 所示的情况,我们可能会最大化快乐用户的数量,但因为我们没有考虑其他桶,我们最终会导致相当多的用户落入无法使用的桶中。
On one end of the spectrum we have maximum happiness for users, where our decision is aimed at maximizing the number of users who are in the happy bucket with our policy on backward compatibility. While we certainly can’t make all users happy, we can always try. However, keep in mind that this means we are choosing a policy without any consideration for the other buckets. This can lead to the situation shown in figure 24.6, where we might maximize the number of happy users, but because we’re not thinking about the other buckets, we end up with quite a lot of users falling into the cannot use bucket.
图 24.6 我们可能会以大量无法使用 API 的用户为代价来最大化快乐用户的数量。
Figure 24.6 We might maximize the number of happy users at the cost of a lot of users who cannot use the API.
另一方面是所有用户的最大可用性,我们的决定试图最大限度地增加绝对可以使用 API 的用户数量。换句话说,频谱的这一端旨在最大限度地减少不能使用桶中的用户数量,因此显然会遇到类似的问题:它不会考虑其他用户在哪个桶中,只要他们不在在不能使用桶中。在图 24.7 中,我们可以看到按这些桶分类的用户的潜在分布,其中我们肯定有最少数量的人在不能使用的桶中。然而,很明显,这不太可能是一个好情况,因为绝大多数用户虽然能够使用 API,但对它一点也不满意,并陷入了疯狂的境地。
On the other end of the spectrum is the maximum usability across all users, where our decision tries to maximize the number of users who can definitely use the API. Put another way, this end of the spectrum is aimed at minimizing the number of users in the cannot use bucket and thus obviously suffers from a similar problem: it doesn’t consider which bucket the other users are in so long as they’re not in the cannot use bucket. In figure 24.7, we can see a potential distribution of users categorized by these buckets, where we certainly have a minimal number of people in the cannot use bucket. However, it’s pretty obvious that this is unlikely to be a good situation as the overwhelming majority of users, while able to use the API, are not at all happy with it and fall into the mad bucket.
图 24.7 我们可以最大限度地减少不能使用 API 的用户数量,但代价是大量疯狂的用户。
Figure 24.7 We might minimize the number of users who cannot use the API but at the cost of lots of mad users.
与往常一样,最佳选择可能位于该范围的中间位置,并且显然取决于这些用户的概况。这可能会尝试平衡最大化幸福感的需求与最小化那些根本无法完全使用 API 的需求。理想情况下,它还可以最大限度地减少疯狂的用户,并寻找一种解决方案,使好的或快乐的用户数量最大化。这可能导致如图 24.8 所示的用户桶分布。我们可以确定的一件事是,通常不可能找到让所有人一直都开心的政策。我们可以做的下一件最好的事情是弄清楚我们是否会以一种解决方案为目标,通过最大限度地减少那些不能完全使用它的人来优先考虑对 API 的无处不在的访问,或者优先考虑最快乐的人。
As always, the best choice probably lies somewhere in the middle of this spectrum and will obviously depend on the profile of these users. This might try to balance the need for maximizing happiness with minimizing those who simply cannot use the API entirely. Ideally, it might also minimize the mad users and search for a solution that maximizes the number of users in either the okay or happy buckets. This might lead to the user distribution of buckets shown in figure 24.8. One thing we can be certain of is that it’s typically impossible to find a policy that makes all people happy all the time. The next best thing we can do is figure out whether we will aim for a solution that prioritizes ubiquitous access to the API by minimizing those who cannot use it entirely or prioritize the most happiness.
图 24.8 平衡分布最大限度地减少了不能使用 API 的用户数量,同时仍然保持一定数量的对政策感到满意的用户。
Figure 24.8 A balanced distribution minimizes the number of users who cannot use the API and still maintains a respectable number of users who are happy with the policy.
Would it be considered backward compatible to change a default value of a field?
想象一下,您有一个 API 可以提供金融服务,而一家大型商业银行是该 API 的重要客户。您的许多小客户想要更频繁的更新和新功能,而这家大银行则希望稳定性高于一切。你能做些什么来平衡这些需求?
Imagine you have an API that caters to financial services with a large commercial bank as an important customer of the API. Many of your smaller customers want more frequent updates and new features while this large bank wants stability above all else. What can you do to balance these needs?
启动 API 后,您立即注意到字段名称中有一个愚蠢且令人尴尬的拼写错误:port_numberwas accidentally called porn_number。你真的想快速改变这一点。在决定是否在不更改版本号的情况下进行此更改之前,您应该考虑什么?
Right after launching an API, you notice a silly and embarrassing typo in a field name: port_number was accidentally called porn_number. You really want to change this quickly. What should you consider before deciding whether to make this change without changing the version number?
想象这样一种场景,当缺少必填字段时,您的 API 意外返回空响应(200 OK在 HTTP 中)而不是错误响应 ( 400 Bad Request),表明缺少必填字段。这个错误在同一版本中修复是否安全,还是应该在未来的版本中解决?如果是未来的版本,这会被认为是主要的、次要的还是根据语义的补丁更改版本控制(semver.org)?
Imagine a scenario where your API, when a required field is missing, accidentally returns an empty response (200 OK in HTTP) rather than an error response (400 Bad Request), indicating that the required field is missing. Is this bug safe to fix in the same version or should it be addressed in a future version? If a future version, would this be considered a major, a minor, or a patch change according to semantic versioning (semver.org)?
版本控制是一种工具,它允许 API 设计人员随着时间的推移更改和发展他们的 API,同时尽可能减少对用户的损害并提供尽可能多的改进。
Versioning is a tool that allows API designers to change and evolve their APIs over time while causing as little detriment and providing as many improvements to users as possible.
Compatibility refers to the property where code written against one version of an API will continue to function against another version.
A new version is considered backward compatible if code written for a previous version continues to function as expected. The question that is left to interpretation is how an API designer defines user expectations.
Often things that might seem harmless (e.g., fixing bugs) can lead to backward incompatible changes. The definition of whether a change is backward compatible is dependent on API users.
有几种不同的流行版本控制策略,每种策略都有自己的优点和缺点。大多数归结为在粒度、简单性、稳定性、新功能、个人用户幸福感和无处不在之间进行权衡可用性。
There are several different popular versioning strategies, each with their own benefits and drawbacks. Most come down to trade-offs between granularity, simplicity, stability, new functionality, individual user happiness, and ubiquity of usability.
正如我们在第 7 章中了解到的,标准的 delete 方法只有一个目标:从 API 中删除资源。然而,在许多情况下,这种永久删除数据(所谓的硬删除) 来自 API 有点太极端了。对于我们想要相当于我们计算机的“回收站”的情况,其中资源被标记为“已删除”但在发生错误的情况下仍可恢复,我们需要一个替代方案。在这个模式中,我们探索了软删除,其中资源从视图中隐藏起来,并且在许多方面表现得就像它们已被删除一样,同时仍然提供取消删除和恢复到完整视图的能力。
As we learned in chapter 7, the standard delete method has one goal: remove a resource from the API. However, in many scenarios this permanent removal of data (a so-called hard deletion) from the API is a bit too extreme. For the cases where we want the equivalent of our computer’s “recycle bin,” where a resource is marked as “deleted” but still recoverable in the case of a mistake, we need an alternative. In this pattern, we explore soft deletion, where resources are hidden from view and behave in many ways as though they have been deleted, while still providing the ability to be undeleted and restored to full view.
从 Windows 95 开始,Microsoft 引入了回收站,这是一个临时存储用户请求删除但尚未从系统中永久删除的文件的区域。问题很简单:太多人从他们的计算机中删除了文件,后来才意识到他们不应该那样做。这可能是一个意外(点击了错误的文件),或者是一个心理错误(认为它是正确的文件但意识到它不是),甚至只是后来改变了主意(认为他们已经完成了一个文件但后来意识到他们确实需要它),但要点仍然是一样的:他们需要一个可恢复的删除操作,并且与永久删除分开。同样的问题经常出现在 API 中。
Starting with Windows 95, Microsoft introduced the Recycle Bin, a temporary storage area for files that a user requested to be deleted but were not yet permanently removed from the system. The problem was simple: too many people deleted files from their computer and then realized later they shouldn’t have done that. It could have been an accident (clicking on the wrong file), or a mental mistake (thinking it was the right file but realizing it wasn’t), or even just a change of heart later (thinking they’re done with a file but realizing later they actually needed it), but the point remains the same: they needed a delete action that was recoverable and separate from a permanent deletion. This same issue pops up in APIs quite often.
我们可能会编写删除具有一组特定属性的所有资源的代码,但也许我们进行了不正确的比较并最终删除了一堆不应该符合条件的资源。我们可能有一个删除数据的脚本,然后意识到我们在不应该的时候不小心运行了它,或者在我们认为可以删除一些资源并后来意识到我们确实需要它们的时候运行了它。对于大型数据集,这会变得更糟。您能想象不小心从存储系统中删除了 1 PB 的用户数据吗?(正是由于这个原因,您无法删除 Google Cloud Storage 服务中的整个存储桶。)
We might write code that deletes all resources with a specific set of properties, but perhaps we do a comparison incorrectly and end up deleting a bunch of resources that shouldn’t have matched the criteria. We might have a script that deletes data and realize that we ran it accidentally when we shouldn’t have, or ran it when we thought it was okay to delete some resources and realized later we actually needed them. This gets even worse with large data sets. Can you imagine accidentally deleting a petabyte of user data from a storage system? (It’s for this reason that you can’t delete an entire bucket in the Google Cloud Storage service.)
在这种情况下,如果我们的删除(即使仅针对某些资源类型)可以是部分的或软的,这样数据不一定会从地球表面删除,而是仅在以下情况下隐藏起来,那就太好了我们正在探索我们的数据(例如,使用标准列表方法)。为了使这种形式的软删除真正有用,我们需要一种方法来取消删除资源,就像我们删除它们一样容易。最后,我们需要一种方法来对资源执行原始的永久删除。
In this case, it’d be wonderful if our deletions, even if only for certain resource types, could be partial or soft, such that the data isn’t necessarily erased from the face of the earth but is instead only hidden from view when we’re exploring our data (e.g., with the standard list method). And to make this form of soft deletion actually useful, we’ll need a way to undelete resources just as easily as we can delete them. Finally, we’ll need a way to perform the original permanent deletion on resources.
幸运的是,实现软删除资源目标的机制非常简单:通过简单的 deleted 布尔标志将已删除状态存储在资源本身上,或者如果资源使用状态枚举,则存储已删除状态。该字段将在资源的整个生命周期中用作标记,以指示我们应该将其与其他未删除的资源区别对待。
Luckily, the mechanism to accomplish our goal of soft deleting resources is pretty simple: store the deleted status on the resource itself, either through a simple deleted Boolean flag or, if the resource uses a state enumeration, a deleted state. This field will serve as a marker throughout the lifetime of the resource to indicate we should treat it differently from the other non-deleted resources.
一旦我们有了这种存储资源已删除状态的新方法,我们就必须修改标准删除方法来更新此状态,而不是完全删除资源。为了处理完全删除,我们将依靠一个特殊的自定义删除方法来接管这个责任。最后,我们需要一种方法来恢复软删除的资源,从而使我们使用自定义的取消删除方法。支持软删除的资源的新生命周期可能如图 25.1 所示。
Once we have this new way of storing the deleted status of resources, we’ll have to modify the standard delete method to update this status rather than remove the resource entirely. To handle complete removal, we’ll rely on a special custom expunge method that will take over this responsibility. Finally, we’ll need a way to restore soft-deleted resources, leading us to a custom undelete method. The new life cycle of a resource supporting soft deletion might look something like figure 25.1.
Figure 25.1 Life cycle of a resource being soft deleted and expunged
在下一节中,我们将探讨此模式的细节以及我们需要涵盖的所有更改和边缘情况以支持软删除。
In the next section, we’ll explore the details of this pattern and all the changes needed and edge cases we’ll need to cover to support soft deletion.
作为API 设计中的大多数主题都是这种情况,这种模式的细节构成了我们为我们完成的大部分工作。在这种情况下,所有的工作都源于我们一个简单的选择,即依赖一个额外的字段来指示资源是否被删除。一旦我们决定了这个额外字段的外观,我们还有很多内容要讨论。
As is the case in most topics in API design, the details of this pattern constitute the majority of the work we have cut out for us. In this case, all of the work stems from our one simple choice of relying on an additional field to indicate whether a resource is deleted. Once we’ve decided how this extra field should look, we have quite a bit more to cover.
首先,我们需要调整几个标准方法,使其行为与第 7 章中定义的方法略有不同。例如,标准删除方法现在需要表现得更像一个标准方法,而不是完全删除资源更新方法,修改资源以将其标记为已删除。此外,标准列表方法需要忽略已删除的资源。毕竟,如果它自动包含已删除的资源,那么它首先会破坏将资源标记为已删除的目的!最后,我们需要新的自定义方法来涵盖每个新的特殊行为:一种用于取消删除我们软删除的资源,另一种用于永久删除资源(就像标准删除方法过去所做的那样)。
First, we’ll need to adjust several of the standard methods to behave slightly differently from how they were defined in chapter 7. For example, rather than deleting the resource entirely, the standard delete method now needs to act a bit more like a standard update method, modifying the resource to mark it as deleted. Additionally, the standard list method will need to ignore deleted resources. After all, if it automatically included deleted resources, then it defeats the purpose of marking resources as deleted in the first place! Finally, we’ll need new custom methods to cover each of the new special behaviors: one for undeleting a resource that we’ve soft-deleted, and another for permanently removing a resource (as the standard delete method used to do).
像往常一样,这些新方法和行为将导致其他复杂的问题需要回答。例如,如果我们尝试软删除已标记为已删除的资源,会发生什么情况?当其他资源引用标记为已删除的资源时,我们是否需要强制执行任何引用完整性规则?其他类似于标准删除方法的方法呢,比如批量删除方法?这些类型的方法应该采用这种新行为还是继续像目前一样表现?
As usual, these new methods and behaviors will lead to other complicated questions to answer. For example, what happens if we attempt to soft delete a resource that’s already marked as deleted? And do we need to enforce any referential integrity rules when other resources reference those that are marked as deleted? What about other methods that are similar to the standard delete method, such as the batch delete method? Should these types of methods adopt this new behavior or continue behaving exactly as they have so far?
要解包的内容很多,所以让我们首先介绍如何在资源上存储新的已删除状态。
This is quite a lot to unpack, so let’s start by covering how we might store the new deleted status on resources.
在为了在资源类型上启用此模式,我们需要一种方法来指示资源应被系统的其余部分视为已删除。在这种情况下,有两个明显的竞争者:一个简单的布尔标志字段和一个更复杂的状态字段。让我们从更简单的选项开始。
In order to enable this pattern on a resource type, we’ll need a way to indicate that a resource should be considered deleted by the rest of the system. In this case, there are two clear contenders: a simple Boolean flag field and a more complex state field. Let’s start by looking at the simpler option.
在大多数情况下,指示资源已被软删除的最佳方法是在资源上定义一个简单的布尔字段。显然这个字段默认为false,因为新创建的资源不应该以 delete 开始它们的生命周期d.
In most cases, the best way to indicate that a resource has been soft deleted is to define a simple Boolean field on the resource. Obviously this field would default to false, since newly created resources should not begin their life cycle as deleted.
Listing 25.1 Soft-deleted resource with a Boolean flag
interface ChatRoom { id: string; // ... deleted: boolean; ❶ }
❶ This flag defines whether the resource should be considered soft deleted.
该字段更有趣的方面是它应该只被视为输出。这意味着如果有人试图设置这个字段(例如,使用标准的更新方法),这个值本身应该被忽略。相反,正如我们稍后将看到的,修改此字段的唯一方法是使用标准删除方法(从更改false为true)或自定义取消删除方法(以相反的方向进行)。这意味着尝试更新资源上已删除字段的代码仍然会成功,但资源将保持不变d.
The more interesting aspect of this field is the fact that it should be treated as output only. This means that if someone attempts to set this field (for example, using the standard update method), the value itself should be ignored. Instead, as we’ll see later, the only way to modify this field is by using the standard delete method (to change from false to true) or the custom undelete method (to go in the opposite direction). This means that code that attempts to update just the deleted field on a resource would still succeed, but the resource would remain unmodified.
Listing 25.2 Example of interacting with an output-only field
let chatRoom = GetChatRoom({ id: "1234" }); ❶ assert chatRoom.deleted == false; chatRoom.deleted = true; ❷ chatRoom = UpdateChatRoom({ ❸ resource: chatRoom, fieldMask: ['deleted'] }); assert chatRoom.deleted == false; ❹
❶ We start by retrieving a resource using the standard get method.
❷ Here we manually set the resource’s deleted field to true.
❸请注意,这不会返回错误结果,而是简单地忽略仅输出字段(例如已删除)。
❸ Notice that this doesn’t return an error result, but instead simply ignores the output-only fields (such as deleted).
❹ Despite the successful result, the resource is still not deleted!
通过这个简单的是或否布尔字段,我们可以启用我们将在 25.3.2 节及以后探讨的所有其他复杂行为。然而,在我们这样做之前,让我们看看另一种选择:状态场地。
With this simple yes-or-no Boolean field, we can enable all the other complicated behavior we’ll explore in section 25.3.2 and beyond. However, before we do that let’s look at one other alternative: a state field.
在在某些情况下,资源可以处于具有明确定义的生命周期的几种状态之一,可以由适当的状态机表示。考虑一个世界,新的聊天室需要得到系统管理员的批准,如果参与者不遵守规则,以后可能仍会被暂停。在这种情况下,我们可能有一个类似于图 25.2 的状态图,其中包含三种状态:待批准、活动和暂停。
In some cases, resources can be in one of several states with a well-defined life cycle that can be represented by a proper state machine. Consider a world where new chat rooms need to be approved by a system administrator and then might still be suspended later if the participants don’t follow the rules. In this case, we might have a state diagram that looks something like figure 25.2, with three states: pending approval, active, and suspended.
Figure 25.2 Life cycle of a resource with state changes
如果我们已经有一个状态字段来跟踪给定资源的状态,而不是添加布尔标志来存储资源是否已被软删除,我们可以选择通过引入新的已删除状态来重用状态字段. 这导致了一个类似于图 25.3 的状态图。
In cases where we already have a state field that keeps track of the state of a given resource, rather than adding a Boolean flag to store whether the resource has been soft deleted, we can opt to reuse the state field by introducing a new deleted state. This leads to a state diagram that looks something like figure 25.3.
Figure 25.3 Adding deleted as a new state option
虽然乍一看这看起来很干净,但事实证明它遇到了一个大问题:当我们要取消删除资源时,我们如何知道目标状态是什么?在这种情况下,我们可以假设ChatRoom资源已恢复的应用程序应始终返回到需要批准的状态,将其视为刚刚重新创建的应用程序(见图 25.4)。但是,这种假设可能并不适用于所有情况。换句话说,使用状态字段来跟踪资源的状态,然后依靠布尔字段来跟踪资源的这个单一正交方面(无论是否被软删除)是完全没问题的).
While this might look quite clean at first, it turns out that it suffers from a big problem: when we go to undelete a resource, how do we know what the target state is? In this case, we can assume that a ChatRoom resource that’s been restored should always go back to requiring approval, treating it the same as if it was just re-created (see figure 25.4). However, this assumption might not hold in all scenarios. In other words, it’s perfectly fine to use a state field to keep track of the state of a resource, and then rely on a Boolean field to keep track of this single orthogonal aspect of the resource (whether it is soft deleted or not).
Figure 25.4 Adding support for the custom undelete method
既然我们已经介绍了指示资源是否被删除的不同方法,我们必须看看这个新指示器对现有方法的影响。让我们首先看看我们需要的标准方法的集合到调整。
Now that we’ve covered the different ways to indicate whether a resource is deleted, we have to look at the effect this new indicator will have on the existing methods. Let’s start by looking at the collection of standard methods we’ll need to adjust.
现在我们有一个新的地方来跟踪资源是否被删除(一个布尔字段或者可能是一个状态字段),我们需要对这个指定做一些事情。特别是,我们需要使具有此特殊属性的资源看起来已被删除,但只是在一定程度上。在某些情况下,方法根本不需要改变。例如,由于 deleted 指标只是输出,不能手动设置,所以标准的创建和更新方法都没有有意义的变化。让我们开始浏览那些确实需要稍微修改的内容。
Now that we have a new place to keep track of whether a resource is deleted (a Boolean field or possibly a state field), we’ll need to do something with this designation. In particular, we’ll need to make resources with this special attribute appear deleted, but only to an extent. In some cases, methods won’t need to change at all. For example, since the deleted indicator is output only and cannot be manually set, both the standard create and update methods have no meaningful changes. Let’s start looking through those that do need to be modified a bit.
什么时候对于软删除资源,标准 get 方法的行为应该与它对任何其他资源的行为完全相同:按请求返回资源。这意味着在删除资源后立即检索资源不会返回404 Not FoundHTTP 错误,而是会返回资源,就好像它根本没有被删除一样。正如我们将看到的,在其他情况下需要额外的工作来访问软删除的资源是有意义的,但是当我们知道资源的标识符时,使用该标识符检索它清楚地表明我们可能想要确定它是否是软删除;并不是说我们确定它已被删除并想查看数据反正。
When it comes to soft-deleted resources, the standard get method should behave exactly as it does with any other resource: return the resource as requested. This means that retrieving a resource immediately after deleting it would not return a 404 Not Found HTTP error, but instead would return the resource as though it wasn’t deleted at all. As we’ll see, in other cases it makes sense to require additional work to access soft-deleted resources, but when we know the identifier of a resource, retrieving it using that identifier is a clear indication that we might want to determine whether it’s soft-deleted; not that we’re sure it’s deleted and want to view the data anyway.
这标准列表方法是需要更改以在面对软删除资源时仍然有用的方法之一。在这种情况下,我们希望提供一种机制来仅查看未删除的资源,而且还能够在用户明确请求时将软删除的资源包含在结果集中。
The standard list method is one of the methods that needs to be changed to remain useful in the face of soft-deleted resources. In this case, we want to provide a mechanism to view only non-deleted resources, but also the ability to include the soft-deleted resources in the result set if the user explicitly requests.
为了使这项工作,我们需要做两件事。首先,标准列表方法应该自动从结果集中排除任何软删除的资源。其次,我们需要扩充请求接口以包含一个新字段,允许用户表达对包含软删除资源的兴趣。
To make this work, we need to do two things. First, the standard list method should automatically exclude any soft-deleted resources from the result set. Second, we need to augment the request interface to include a new field that allows a user to express an interest to have soft-deleted resources included.
Listing 25.3 Including deleted results in the standard list methods
abstract class ChatRoomApi { @get("/chatRooms") ListChatRooms(req: ListChatRoomsRequest): ListChatRoomsResponse; } interface ListChatRoomsRequest { pageToken?: string; maxPageSize?: number; includeDeleted?: boolean; ❶ filter?: string; } interface ListChatRoomsResponse { resources: ChatRoom[]; nextPageToken: string; }
❶ If set to true, soft-deleted resources will be included in the result set.
这个定义提出了一个明显的问题:为什么不使用filter字段包括软删除的资源?这有几个原因。首先,不能保证所有标准列表方法都支持过滤。如果他们不这样做,我们仍然需要一种机制来支持在结果集中包含软删除的资源,在这种情况下,我们将不得不在所示的布尔标志或仅支持的过滤器之间进行选择只关心软删除资源的非常有限的语法。
This definition raises an obvious question: why not use the filter field to include the soft-deleted resources? There are a few reasons for this. First, there’s no guarantee that all standard list methods will support filtering. In the case that they don’t, we’ll still need a mechanism to support including soft-deleted resources in the result set, and in that case we’ll have to choose between a Boolean flag as shown or a filter that only supports a very limited grammar for caring only about soft-deleted resources.
其次,虽然这两个概念密切相关,但它们实际上是相互正交的。该filter字段负责获取一组可能的结果并将这些结果缩减为仅符合一组特定条件的结果。includeDeleted领域_负责做相反的事情:在应用过滤器之前扩大潜在的结果集。
Second, while these two concepts are closely related, they are actually orthogonal to one another. The filter field is responsible for taking a set of possible results and whittling those results down to only those matching a specific set of criteria. The includeDeleted field is responsible for doing the opposite: enlarging the potential result set before even applying the filter.
这种组合确实意味着我们有一些令人困惑的方式来只显示软删除的资源,但它也意味着 API 在每个不同的目的上保持一致和清晰场地秒。
This combination does mean that we have a bit of a confusing way for only showing the soft-deleted resources, but it also means that the API remains consistent and clear in the purpose of each of the different fields.
Listing 25.4 Example to view all soft-deleted resources
softDeletedChatRooms = ListChatRooms({ filter: "deleted: true", ❶ includeDeleted: true, ❷ });
❶ To show only soft-deleted resources, we need to limit results using a filter.
❷为了确保软删除的资源被包含在结果集中(然后被过滤),我们首先需要通过明确地包含它们来表明这一点。
❷ To ensure that soft-deleted resources are included in the result set (and then filtered), we first need to indicate that by including them explicitly.
作为我们已经了解到,标准的 delete 方法需要扩充,这样现在,它应该使用 25.3.1 节中讨论的指示符将资源标记为已删除,而不是实际删除资源。它还应该返回新修改的资源。
As we’ve already learned, the standard delete method needs to be augmented so that now, rather than actually removing a resource, it should mark the resource as deleted using the indicator discussed in section 25.3.1. It should also return the newly modified resource.
Listing 25.5 Definition of the modified standard delete method
abstract class ChatRoomApi { @delete("/{id=chatRooms/*}") DeleteChatRoom(req: DeleteChatRoomRequest): ChatRoom; ❶ } interface DeleteChatRoomRequest { id: string; }
❶增强的标准 delete 方法现在返回资源,而不是像以前那样返回 void。
❶ The augmented standard delete method now returns the resource rather than void as before.
新的问题是,当您尝试软删除已被软删除的资源时究竟应该发生什么?也就是说,如果资源被标记为已删除,标准删除方法是否应该保持原样?或者抛出错误?或者完全做其他事情?
The new question is what exactly should happen when you attempt to soft delete a resource that’s already been soft deleted? That is, if a resource is marked as deleted, should the standard delete method leave it as is? Or throw an error? Or do something else entirely?
答案遵循我们在第 7 章中关于重复删除的相同原则:能够区分命令式(此请求导致资源被删除)和声明式(资源被删除,无论是否删除)很重要这是此请求的结果)。因此,新的标准 delete 方法返回错误结果(例如,412 Precondition FailedHTTP 错误) 表示资源已被软删除,因此无法按预期执行请求。
The answer follows the same principles we learned about in chapter 7 about repetitive deletion: it’s important to be able to distinguish between the imperative (that this request caused the resource to be deleted) and the declarative (that the resource is deleted, whether or not it was the result of this request). Because of this, it’s important that the new standard delete method return an error result (e.g., a 412 Precondition Failed HTTP error) to indicate that the resource has already been soft deleted and therefore the request cannot be executed as expected.
现在我们已经介绍了相关的标准方法,让我们开始研究我们需要实现的新自定义方法,从自定义开始取消删除方法。
Now that we’ve covered the relevant standard methods, let’s start looking at the new custom methods we’ll need to implement, starting with the custom undelete method.
一支持软删除的主要好处之一是数据不会被标准删除方法永久删除。相反,通过将资源标记为已删除但保持基础数据完好无损,我们在某种程度上避免了各种可能的错误 API 调用。为了使这种绝缘真正有价值,我们需要一种方法来消除我们的错误。换句话说,如果我们删除了一个资源,后来决定我们不应该这样做,那么一定有一种方法可以恢复或取消删除资源。您可能会猜到,我们可以使用自定义方法来完成此操作。
One of the main benefits of supporting soft deletion is the fact that data isn’t permanently deleted by the standard delete method. Instead, by marking the resource as deleted but leaving the underlying data intact, we’re somewhat insulated from the variety of possible mistaken API calls. To make this insulation really worthwhile though, we need a way of undoing our mistakes. In other words, if we delete a resource and decide later that we should not have done so, there must be a way to restore or undelete a resource. As you might guess, we can do this with a custom method.
Listing 25.6 Definition of the custom undelete method
abstract class ChatRoomApi { @post("/{id=chatRooms/*}:undelete") UndeleteChatRoom(req: UndeleteChatRoomRequest): ❶ ChatRoom; } interface UndeleteChatRoomRequest { id: string; }
❶ Just like a standard update method, we return the resource itself.
自定义取消删除方法很简单。给定软删除资源的标识符,取消标记该资源,使其不再显示为已删除。那么问题就变成了另一个关于幂等性和重复性的问题:如果我们试图取消删除的资源当前没有被软删除怎么办?在这种情况下,就像我们对标准删除方法所做的更改一样,响应应该是错误结果(例如,412 Precondition FailedHTTP 错误)。目的是再次确保用户能够区分导致结果的请求(命令式)或结果为真但不一定由特定请求引起(声明性的)。
The custom undelete method is simple. Given an identifier of a soft-deleted resource, unmark the resource so that it no longer appears deleted. The question then becomes yet another about idempotence and repetition: what if the resource we’re trying to undelete is not currently soft deleted? In this case, just as in the case of the changes we made to the standard delete method, the response should be an error result (e.g., a 412 Precondition Failed HTTP error). The purpose is, yet again, to make sure users can tell the difference between a request causing a result (imperative) or a result being true but not necessarily caused by a specific request (declarative).
和所有这些关于软删除提供的新功能的讨论,我们都在某种程度上掩盖了完全删除的旧功能。但是仅仅因为我们希望避免我们的 API 回收站出现错误并不意味着我们永远不希望能够真正清空它。不幸的是,我们将标准删除方法重新用于仅进行软删除,那么我们如何处理旧式的完全删除呢?
With all this talk of new functionality provided by soft deletion, we’ve kind of glossed over the old functionality of full deletion. But just because we want to be protected from our mistakes with our API recycle bin doesn’t mean we never want the ability to actually empty it. Unfortunately, we’ve repurposed the standard delete method to do only soft deletion, so how do we handle the old style of full deletion?
为了弥补这一差距,我们有两个选择:在标准删除方法上提供额外参数或依赖自定义方法。虽然标准删除请求中的额外参数可能会减少接口和自定义方法,但它有几个缺点。首先是HTTPDELETE方法通常不接受请求正文。这意味着我们必须在查询字符串中提供特殊的新参数。最终,这意味着删除资源 () 的典型 HTTP 请求的行为与使用附加查询参数 ( )DELETE /chatRooms/1234的相同请求的行为截然不同。DELETE /chatRooms/1234?expunge=true通常,附加参数,尤其是查询字符串中的参数,不应显着改变请求的行为和结果。否则,为什么有多种方法来删除和创建?为什么不只是一个带有?action=create参数的请求?
To address this gap, we have two options: provide an extra parameter on the standard delete method or rely on a custom method. While the extra parameter on the standard delete request might result in fewer interfaces and custom methods, it has a couple of drawbacks. The first is that the HTTP DELETE method doesn’t typically accept a request body. This means that our special new parameter will have to be provided in the query string. Ultimately, this means that the behavior of a typical HTTP request to delete a resource (DELETE /chatRooms/1234) will do something drastically different from the same request with an additional query parameter (DELETE /chatRooms/1234?expunge=true). In general, additional parameters, particularly those in a query string, should not drastically alter the behavior and result of the request. Otherwise, why have multiple methods to delete versus create? Why not just a single request with ?action=create as a parameter?
在标准删除请求中依赖参数的另一个缺点是,由于权限和访问控制,通常控制对特定 API 方法的访问比控制具有不同参数集的相同方法更容易。在这种情况下,如果我们想禁止用户永久删除资源,同时允许同一用户软删除资源的能力,我们将不得不允许访问标准删除方法,但前提是该方法上设置了特殊标志假的。虽然可能,但实施起来肯定更复杂。
The other drawback of relying on a parameter in the standard delete request is that, due to permissions and access control, in general, it’s easier to control access to specific API methods than it is to control the same method with different sets of parameters. In this case, if we wanted to prohibit a user from permanently deleting resources while allowing that same user the ability to soft delete resources, we would have to allow access to the standard delete method, but only if the special flag on the method was set to false. While possible, this is certainly more complex to implement.
由于这些原因,最好依靠单独的方法来处理从系统中完全删除资源的责任。
Because of these reasons, it’s probably better to rely on a separate method to handle the responsibility of completely removing resources from the system.
Listing 25.7 Definition of the custom expunge method
abstract class ChatRoomApi { @post("/{id=chatRooms/*}:expunge") ExpungeChatRoom(req: ExpungeChatRoomRequest): void; ❶ } interface ExpungeChatRoomRequest { id: string; }
❶ Just like the old standard delete method, expunging should return an empty result.
由于我们不得不担心修改后的标准删除方法和自定义取消删除方法的前提条件和错误结果,因此值得就删除方法提出同样的问题。在这种情况下,由于结果是完全删除的资源,问题略有不同:我们能否在任何资源上调用此方法,无论它是否已被软删除?或者我们是否需要先软删除资源,然后才将其永久删除?
Since we had to worry about preconditions and error results with the modified standard delete method and the custom undelete method, it’s worth asking the same question regarding the expunge method. In this case, since the result is a fully deleted resource, the question is slightly different: can we call this method on any resource, whether or not it’s already been soft deleted? Or do we need to first soft delete a resource and only then remove it permanently?
虽然可能有一些原因需要执行第一个软删除资源的中间步骤,但从长远来看,这很可能只不过是 API 的一个减速带。换句话说,这主要是一种不便,将应该是单个 API 请求的内容变成了两个,而实际上并没有做太多其他事情。因此,几乎总是可以在任何资源上调用自定义删除方法,无论它是否已经被软化删除。
While there may be some reasons for going through the intermediate step of first soft deleting a resource, this is likely to become nothing more than an API speed bump in the long run. In other words, it’ll mostly be an inconvenience and turn what should be a single API request into two, without really doing much else. As a result, it should almost always be possible to call the custom expunge method on any resource, whether or not it’s already been soft deleted.
尽管通常软删除和显式永久删除就足够了,在某些情况下我们可能希望经常清空回收站。例如,当我们在 Gmail 中删除一封邮件时,它会进入垃圾箱(即软删除)。但是,在回收站中放置 30 天后,邮件将被永久删除。这种模式,我们在第 10 章中看到了一些 LRO 的行为,对于软删除资源来说并不罕见。唯一的问题是确定如何最好地实施它。
While often soft deletion and explicit permanent deletion are sufficient, there may be cases where we want to have the recycle bin emptied every so often. For example, when we delete a message in Gmail, it goes into the trash (i.e., soft deleted). However, after 30 days in the trash, a message is permanently deleted. This pattern, which we saw a bit of in chapter 10 with the behavior of LROs, is not unusual for soft-deleted resources. The only issue is determining how best to implement it.
与 LRO 一样,软删除资源也应该有过期时间。这意味着除了单独的指定之外,我们还需要在任何支持软删除的资源上添加一个新字段,如第 25.3.1 节所述。这个新领域应该被称为expireTime并且应该在资源被删除时立即设置,根据一些预定义的策略计算预期的过期时间(例如,30 天后过期)。如果资源曾经被标记为除已删除之外的任何其他内容,则应将过期时间设置为明确的null值. 这意味着如果我们取消删除资源,它的过期时间应该重新设置吨。
Just as with LROs, soft-deleted resources should also have an expiration time. This means we’ll need a new field on any resource that supports soft deletion in addition to the designation alone, described in section 25.3.1. This new field should be called expireTime and should be set immediately when a resource is deleted, calculating the expected expiration time according to some predefined policy (e.g., expires after 30 days). If a resource is ever marked as anything other than deleted, the expiration time should be set to an explicit null value. This means that if we undelete a resource, it’s expiration time should be reset.
Listing 25.8 Adding a new expiration time field to a resource
interface ChatRoom { id: string; // ... deleted: boolean; expireTime: Date; ❶ }
❶ This field keeps track of when a resource should expire and be permanently removed.
这将确定事物何时过期的负担放在执行过期的方法(在本例中,标准删除方法)上,但也有一个很好的副作用,即默认情况下允许任何策略更改仅影响新的软删除资源。例如,如果我们将政策从 30 天更改为 45 天,所有已经软删除的资源仍将在软删除日期后 30 天被删除,但任何新的软删除资源将适用新政策。
This puts the burden of determining when things expire on the method doing the expiring (in this case, the standard delete method), but also has the nice side effect of allowing any policy changes to only affect newly soft-deleted resources by default. For example, if we change the policy from 30 days to 45 days, all of the already soft-deleted resources will still be deleted 30 days from their soft-deletion date, but any newly soft-deleted resources will fall under the new policy.
此策略还确保用于处理此资源过期的代码简单明了。本质上,我们可以依赖两个简单的条件:如果资源被标记为已删除(例如,resource.deleted == true)并且过期时间晚于或等于现在(例如,resource.expireTime >= Date.now()),我们应该简单地删除资源完全。
This policy also ensures the code for handling this expiration of resources is simple and to the point. In essence, we can rely on two simple conditions: if a resource is flagged as deleted (e.g., resource.deleted == true) and the expiration time is after or equal to now (e.g., resource.expireTime >= Date.now()), we should simply remove the resource entirely.
其他值得通过软删除解决的主题是参照完整性。简而言之,我们必须决定软删除的资源是否仍然可以被其他资源引用。我们在第 13 章中非常详细地讨论了这些类型的问题,主要内容是不同的 API 在引用完整性方面有不同的模式。其中一些可能会限制中断引用的能力。其他人可能只是级联更改(例如,如果相关资源被删除,它可能会删除涉及的其他资源)。其他人可能仍然不理会参考资料,对于任何试图在未来使用参考资料的人来说,这就像一颗定时炸弹。
Another topic worth addressing with soft deletion is that of referential integrity. In short, we have to decide whether soft-deleted resources can still be referenced by other resources. We went through these types of issues in quite a lot of detail in chapter 13, with the main takeaway being that different APIs have different patterns when it comes to referential integrity. Some of them might restrict the ability to break a reference. Others might simply cascade the change (e.g., if a related resource is deleted, it might delete the other resources involved). Others still might leave the reference alone, like a ticking time bomb for anyone attempting to use the reference in the future.
在软删除的情况下,情况非常相似。因此,规则应该保持不变。这意味着无论普通标准删除方法强制执行何种模式,更改为启用软删除都不应该改变这部分行为。因此,如果删除曾经被阻止以保持引用完整性,那么软删除应该具有相同的限制,而不管从技术上讲引用不会被破坏的事实。
In the case of soft deletion, the scenarios are quite similar. As a result, the rules should remain the same. This means that whatever pattern was enforced by the normal standard delete method, changing to enable soft deletion shouldn’t alter that portion of the behavior. So if deletion used to be prevented to maintain referential integrity, soft deletion should have the same restrictions, regardless of the fact that technically the reference wouldn’t be broken.
虽然当涉及到我们能够撤销错误删除的最初目标时,这确实带来了一些复杂性(因为现在我们不能在某些情况下完全按照以前的方式恢复资源),改变行为的额外复杂性,仅在资源过期或删除时更改它,根本不是值得。
While this does present some complications when it comes to our original goal of being able to undo a mistaken deletion (as now we can’t just restore a resource exactly as it was before in some cases), the additional complexity of changing behavior, only to have it change at the time where a resource is expired or expunged, is simply not worthwhile.
甚至尽管我们已经介绍了现有标准方法所需的各种更改,但我们对其他相关方法只字未提。例如,如果我们支持批处理方法,那么批处理删除方法就有点为难了。该方法是否应该像往常一样继续运行,永久地批量删除资源?还是应该同样修改为默认软删除?如果要修改,是不是也应该有批量删除的方法?
Even though we’ve covered the various changes needed for the existing standard methods, we’ve said nothing about other related methods. For example, if we support batch methods, the batch delete method presents a bit of a quandary. Should that method continue behaving as it always has, permanently deleting resources in bulk? Or should it likewise be modified to soft delete by default? And if it should be modified, should there also be a batch expunge method?
简单的答案是应该修改它,如果需要那种类型的行为,应该有一个批量删除方法。通常,当我们更改标准方法行为(在本例中为标准 delete 方法)时,我们应该考虑其他相关方法,就好像它们使用了那些新更改的方法一样。
The simple answer is that it should be modified, and there should be a batch expunge method if that type of behavior is required. In general, when we alter standard method behavior (in this case, the standard delete method), we should consider other related methods almost as if they used those newly altered methods.
Listing 25.9 Example implementation of the batch delete method
abstract class ChatRoomApi { @post("/chatRooms:batchDelete") BatchDeleteMessages(req: BatchDeleteMessagesRequest): void { for (let id of req.ids) { ChatRoomApi.DeleteMessage({ id: id }); ❶ } } }
❶ The soft-delete behavior of a batch delete method will be inherited.
因此,批量删除方法应该定义为标准删除但批量删除,因此将继承修改后的标准删除方法的新行为以支持软删除删除。
As a result, the batch delete method should be defined as the standard delete but in bulk, and therefore will inherit the new behavior from the modified standard delete method to support soft deletion.
最后,由于我们很少在第一次尝试时就把事情做好,我们应该解决如何安全地将软删除支持添加到以前没有的 API 的问题。正如我们在第 24 章中了解到的,制定关于什么构成向后兼容更改的策略至关重要,并且在以前仅支持硬删除的 API 中实现软删除当然是我们需要检查的更改。
Finally, as we very rarely get things right on the first try, we should address the concern of how to safely add soft-deletion support to an API that previously didn’t have it. As we learned in chapter 24, it’s critical to have a policy on what constitutes a backward compatible change, and implementing soft deletion in an API that previously only supported hard deletion is certainly a change we need to examine.
与几乎所有与版本控制相关的问题一样,答案非常没有吸引力:视情况而定。通常,更改方法的行为不太可能是安全的更改,但在许多情况下,这种对软删除的更改可能完全没问题。例如,如果我们对资源实施 24 小时过期窗口的软删除,资源不会立即删除,而是在 24 小时后删除,这真的是一件大事吗?如果被删除的信息不是特别重要(例如,没有管理此类数据处理的法律),则尤其如此。
As with almost all issues related to versioning, the answer is pretty unappealing: it depends. Generally, changing the behavior of a method is unlikely to be a safe change to make, but in many cases, this type of change to soft deletion might be perfectly fine. For example, if we implement soft deletion with a 24-hour expiration window on resources, is it really such a huge deal that resources aren’t immediately deleted but instead are removed 24 hours later? This is especially true if the information being deleted isn’t particularly important (e.g., there are no laws governing the disposal of such data).
因此,是否可以以不间断的方式将软删除功能添加到现有 API 中,确实有待解释。这几乎肯定取决于最终结果(例如,是否也实施过期),但最肯定的是需要对数据的目的以及与该数据的隐私和安全相关的期望和法规有深入的了解. 为了安全起见,假设它在某种意义上可能被认为是向后不兼容的,通常是个好主意。因此,在实现软删除时增加主版本号(或等效版本)是完美的可接受的。
As a result, whether or not soft-delete functionality can be added to an existing API in a non-breaking way is really open for interpretation. It will almost certainly depend on the end result (e.g., whether expiration is implemented as well), but most definitely will require a solid understanding of the purpose of the data as well as the expectations and regulations related to the privacy and security of that data. To be on the safe side, it’s generally a good idea to assume that it might be considered backward incompatible in some sense. As a result, incrementing the major version number (or equivalent) when implementing soft deletion is perfectly acceptable.
推杆一切都在一起,清单 25.10 显示了我们如何定义一个接口来支持我们的软删除聊天美联社我。
Putting everything together, listing 25.10 shows how we might define an interface for supporting soft deletion in our chat API.
Listing 25.10 Final API definition
abstract class ChatRoomApi { @delete("/{id=chatRooms/*}") DeleteChatRoom(req: DeleteChatRoomRequest): ChatRoom; @get("/chatRooms") ListChatRooms(req: ListChatRoomsRequest): ListChatRoomsResponse; @post("/{id=chatRooms/*}:expunge") ExpungeChatRoom(req: ExpungeChatRoomRequest): void; } interface ChatRoom { id: string; // ... deleted: boolean; expireTime: Date; } interface ListChatRoomsRequest { pageToken?: string; maxPageSize: number; filter?: string; includeDeleted?: boolean; } interface ListChatRoomsResponse { resources: ChatRoom[]; nextPageToken: string; } interface ExpungeChatRoomRequest { id: string; }
柔软的一般来说,删除是在允许用户从 API 中永久删除特定信息和防止他们搬起石头砸自己的脚之间进行权衡。因此,软删除是否是一个好主意实际上取决于您的 API 的情况。
Soft deletion in general is a trade-off between permitting users to remove specific information permanently from the API and preventing them from shooting themselves in the foot. As a result, it really depends on the circumstances of your API to decide whether soft deletion is a good idea.
例如,如果存储的数据以某种方式受到监管,那么标准方法完全按照预期进行,在请求时准确地永久删除信息可能更为重要。在这些情况下,制定更全面的数据备份策略作为防止意外数据丢失的方法可能是有意义的。通过这种方式,请求删除数据的用户可以得到他们想要的,但数据仍会按照任何相关法规可能可接受的策略进行保留。
For example, if the data being stored is regulated in some way, it might be far more important that the standard methods do exactly as expected, removing information permanently exactly when requested. In cases like these, it might make sense to work on a more thorough data backup strategy as a way of preventing accidental data loss. This way, users requesting data be removed get what they want, but data is still preserved with a policy that is likely acceptable according to any relevant regulations.
这也切入了另一个方向。一些法规可能侧重于确保数据永远不会被永久删除。在这种情况下,我们可能希望避免实施资源过期或自定义删除方法以避免与这些方法发生冲突规定。
And this cuts in the other direction as well. Some regulations might focus on ensuring that data is never permanently deleted. In cases like these, we may want to avoid implementing resource expiration or the custom expunge method to avoid running afoul of those regulations.
什么时候使用布尔标志与状态字段来表示资源已被软删除是否有意义?在同一个资源上同时拥有两个字段是否有意义?为什么或者为什么不?
When does it make sense to use a Boolean flag versus a state field to represent that a resource has been soft deleted? Does it ever make sense to have both fields on the same resource? Why or why not?
How should users indicate that they wish to see only the soft-deleted resources using the standard list method?
What should happen if the custom expunge method is called on a resource that hasn’t yet been soft deleted? What about calling the custom undelete method on the same resource?
When does it make sense for soft-deleted resources to expire? How should the expiration deadline be indicated?
In what circumstances might we consider adding support for soft deletion to be a backward compatible change? What about a backward incompatible change?
Soft-deletion refers to the ability to mark a resource as deleted without actually removing it from the API service’s storage systems.
Typically resources are marked as being deleted using a Boolean flag field called deleted, but they may also add a new deleted state in an already existing state field.
404 Not Found支持软删除的资源的标准方法行为需要一些小的更改(例如,标准的 get 方法在请求软删除资源时不应返回错误)。
Standard method behavior for resources supporting soft deletion require a few minor changes (e.g., the standard get method should not return a 404 Not Found error when a soft-deleted resource is requested).
Soft-deleted resources may be restored using a custom undelete method and permanently removed using a custom expunge method.
Whichever referential integrity guidelines were in place for the standard delete method (e.g., cascading deletes of referenced resources) should similarly apply for soft deleting resources.
在我们无法保证所有请求和响应都将按预期完成其旅程的世界中,我们将不可避免地需要在发生故障时重试请求。对于幂等的 API 方法来说,这不是问题;但是,我们需要一种方法来安全地重试请求,而不会导致重复工作。此模式提供了一种机制,可以在 Web API 出现故障时安全地重试请求,而不管该方法的幂等性如何。
In a world where we cannot guarantee that all requests and responses will complete their journeys as intended, we’ll inevitably need to retry requests as failures occur. This is not an issue for API methods that are idempotent; however, we’ll need a way to safely retry requests without causing duplicated work This pattern presents a mechanism to safely retry requests in the face of failure in a web API, regardless of the idempotence of the method.
不幸的是,我们生活在一个非常不确定的世界中,在涉及远程网络的任何事物中尤其如此。鉴于现代网络的绝对复杂性和当今可用的传输系统的多样性,我们能够可靠地传输消息真是一个奇迹!遗憾的是,这种可靠性问题在 Web API 中同样普遍。此外,随着 API 在越来越小的设备上通过无线网络越来越频繁地使用,在越来越远的距离上发送请求和接收响应意味着我们实际上必须比在小型有线本地网络上更关心可靠性网络。
Unfortunately, we live in a very uncertain world, and this is especially so in anything involving a remote network. Given the sheer complexity of modern networks and the variety of transmission systems available today, it’s a bit of a miracle that we’re able to reliably transfer messages at all! Sadly, this issue of reliability is just as prevalent in web APIs. Further, as APIs are used more frequently over wireless networks on smaller and smaller devices, sending requests and receiving responses over longer and longer distances means that we actually have to care about reliability more than we would on, say, a small, wired, local network.
这种网络固有的不可靠性的必然结论是客户端发送的请求可能并不总是到达服务器的场景。即便如此,服务器发送的响应也不一定总能到达客户端。当这种情况发生在幂等方法(例如,不改变任何东西的方法,如标准 get 方法)时,这真的没什么大不了的。如果我们没有得到回应,我们总是可以再试一次。但是如果请求不是幂等的呢?要回答这个问题,我们需要更仔细地了解一下情况。
The inevitable conclusion of this inherent unreliability of networks is the scenario where a request sent by the client might not always arrive at the server. And even if it does, the response the server sends might not always arrive at the client. When this happens with idempotent methods (e.g., methods that don’t change anything, such as a standard get method), it’s really not a big deal. If we don’t get a response, we can always just try again. But what if the request is not idempotent? To answer this question, we need to look a bit closer at the situation.
一般来说,当我们发出请求没有得到响应时,有两种可能。在一种情况下(如图 26.1 所示),API 服务器从未收到请求,因此我们当然没有收到回复。在另一个(如图 26.2 所示)中,请求已收到,但响应从未返回给客户端。
In general, when we make a request and don’t get a response, there are two possibilities. In one case (shown in figure 26.1), the request was never received by the API server, so of course we haven’t received a reply. In the other (shown in figure 26.2), the request was received but the response never made it back to the client.
Figure 26.1 A request was sent but never received.
Figure 26.2 A response was sent but never received.
虽然这两种情况都很不幸,但第一种情况(API 服务器甚至从未收到请求)是完全可以恢复的情况。在这种情况下,我们可以重试请求。由于从未收到过,基本上就好像该请求根本没有发生过一样。第二种情况更可怕。在这种情况下,API 服务器接收并处理了请求,并发送了响应——只是从未到达。
While both of these scenarios are unfortunate, the first one (where a request is never even received by the API server) is a completely recoverable situation. In this case, we can just retry the request. Since it was never received, it’s basically as though the request never happened at all. The second case is far scarier. In this scenario, the request was received, processed by the API server, and the response was sent—it just never arrived.
但最大的问题是我们无法区分这两种情况。无论问题是在发送请求时还是在接收响应时发生,客户端所知道的只是期望有一个响应,但它从未出现过。因此,我们必须做好最坏的打算:请求已经发出,但我们还没有被告知结果。我们可以做什么?
The biggest problem though is that we can’t tell the difference between these two scenarios. Regardless of whether the problem happened when sending the request or on receiving the response, all the client knows is that there was a response expected and it never showed up. As a result, we have to prepare for the worst: that the request made it but we haven’t been informed about the result. What can we do?
这是该模式的主要目标:定义一种机制,通过该机制我们可以防止跨 API 重复请求,特别是针对非幂等方法。换句话说,我们应该能够在不知道是否收到请求的情况下安全地重试方法(即使是那些发射导弹的方法),也不用担心重试导致方法被执行两次。
This is the main goal of this pattern: to define a mechanism by which we can prevent duplicate requests, specifically for non-idempotent methods, across an API. In other words, we should be able to safely retry methods (even ones that launch missiles) without knowing whether the request was received, and without worrying about the method being executed twice as a result of retrying.
这个pattern 探索了为我们想要确保服务一次且仅一次的每个请求提供唯一标识符的想法。使用这个标识符,我们可以清楚地看到当前传入的请求是否已经被 API 服务处理过,如果是,则可以避免再次对其进行操作。
This pattern explores the idea of providing a unique identifier to each request that we want to ensure is serviced once and only once. Using this identifier, we can clearly see whether a current incoming request was already handled by the API service, and if so, can avoid acting on it again.
这就引出了一个棘手的问题:当捕获到重复请求时,我们应该返回什么作为结果?虽然返回一个错误说明此请求已被处理当然就足够了,但实际上并不是那么有用。例如,如果我们发送一个创建新资源的请求但从未得到响应,那么当我们发出另一个请求时,我们希望得到一些有用的东西,特别是所创建资源的唯一 ID。一个错误确实可以防止重复,但它并不是我们在结果中寻找的。
This leads to a tricky question: what should we return as the result when a duplicate request is caught? While returning an error stating that this request was already handled is certainly sufficient, it’s not all that useful in practical terms. For example, if we send a request to create a new resource and never get a response, when we make another request we’re hoping to get something useful back, specifically the unique ID of the resource that was created. An error does prevent duplication, but it’s not quite what we’re looking for in a result.
为了处理这个问题,我们可以缓存与请求 ID 一起提供的响应消息,前提是当发现重复项时,我们可以返回响应,因为它会在第一次发出请求时返回。图 26.3 显示了使用请求 ID 的此流程的示例。
To handle this, we can cache the response message that went along with the request ID provided that when a duplicate is found, we can return the response as it would have been returned when the request was first made. An example of this flow using request IDs is shown in figure 26.3.
Figure 26.3 Overview of the sequence of events for deduplicating requests
现在我们已经了解了这个简单模式如何在较高层次上工作,让我们开始实施吧细节。
Now that we have an idea of how this simple pattern works at a high level, let’s get into the implementation details.
这使此模式起作用的第一件事是定义请求标识符字段,以便我们可以在确定请求是否已被 API 接收和处理时使用它。该字段将出现在客户端为需要重复数据删除的任何方法发送的任何请求接口上(在这种情况下,这通常只是非幂等的方法)。让我们首先更仔细地查看该字段以及最终将存储在其中的值。
The first thing needed to make this pattern work is a definition of a request identifier field so that we can use it when determining whether a request has been received and processed by the API. This field would be present on any request interfaces clients send for any methods that require deduplication (in this case, that’s generally just the methods that are not idempotent). Let’s begin by looking more closely at this field and the values that will end up stored in it.
一种请求标识符只不过是一个标准的 ID 字段;然而,这个字段不是存在于资源接口上,而是存在于特定的请求接口上(见清单 26.1)。正如资源上的 ID 字段在整个 API 中唯一标识资源一样,请求标识符旨在实现相同的目标。这里要注意的是,虽然资源标识符几乎总是永久性的(第 6 章),但请求标识符更像是一次性值。而且因为它们是客户用来识别他们的传出请求的东西,所以由客户自己选择它们是绝对重要的F。
A request identifier is nothing more than a standard ID field; however, rather than living on a resource interface, this field lives on certain request interfaces (see listing 26.1). Just as an ID field on a resource uniquely identifies the resource across the entire API, a request identifier aims to accomplish the same goal. The catch here is that while resource identifiers are almost always permanent (chapter 6), request identifiers are a bit more like single-use values. And because they’re something a client uses to identify their outgoing requests, it’s absolutely critical that they be chosen by the client itself.
Listing 26.1 Definition of a request with an identifier field
interface CreateChatRoomRequest { requestId?: string; ❶ resource: ChatRoom; }
❶ Defining the request identifier as an optional string field on the request interface
不幸的是,正如我们在第 6 章中了解到的那样,允许客户选择标识符通常不是一个好主意,因为他们往往会做出糟糕的选择。但是,由于这是必要的,我们能做的最好的事情就是指定一种格式,建议随机选择标识符,并强制执行指定的格式(例如,我们真的不希望有人发送 ID 设置为 1 或“A B C D”)。相反,请求标识符应该遵循与资源标识符相同的标准(在第 6 章中讨论)。
Unfortunately, as we learned in chapter 6, it’s usually a bad idea to allow clients to choose identifiers, as they tend to choose poorly. Since it’s a necessity, though, the best we can do is specify a format, recommend that the identifier be chosen randomly, and enforce the indicated format (e.g., we really wouldn’t want someone sending a request with its ID set to 1 or “abcd”). Instead, request identifiers should follow the same standards for resource identifiers (discussed in chapter 6).
尽管请求标识符不像资源标识符那样永久,但这并不会使它们变得不那么重要。因此,如果 API 服务器收到带有无效请求 ID 的传入请求,它应该拒绝该请求并抛出错误(例如,400 Bad RequestHTTP 错误)。请注意,这不同于将请求标识符留空或缺失。在那种情况下,我们应该继续处理,就好像请求永远不会被重试一样,绕过第 26.2 节中讨论的所有缓存,并像任何其他 API 方法一样运行。
Even though request identifiers are not as permanent as those for resources, this doesn’t make them any less important. As a result, if the API server receives an incoming request with an invalid request ID, it should reject the request and throw an error (e.g., a 400 Bad Request HTTP error). Note that this is different from leaving a request identifier blank or missing. In that case, we should proceed as though the request is never going to be retried, bypassing all the caching discussed in section 26.2, and behaving as any other API method would.
一经常出现的问题是,“为什么不直接从请求本身派生标识符呢?” 乍一看,这似乎是一个非常优雅的解决方案:对请求主体执行某种散列,然后确保无论何时我们再次看到它,我们都知道它不应该再次处理。
One question that often comes up is, “Why not just derive an identifier from the request itself?” At first glance this seems like quite an elegant solution: perform some sort of hash on the request body and then ensure that whenever we see it again we know it shouldn’t be processed again.
这种方法的问题是我们不能保证我们实际上不需要执行相同的请求两次。而不是来自客户端的明确声明(“我绝对想确保这个请求只被处理一次”),我们隐式地确定预期的行为,而不提供明确的方法来选择退出这种重复数据删除。显然,用户可以提供一些额外的标头或无意义的查询字符串参数来使请求看起来不同,但这已经交换了默认值。
The issue with this method is that we cannot guarantee that we won’t actually need to execute the same request twice. And instead of an explicit declaration from the client (“I definitely want to ensure this request is only processed once”), we’re implicitly determining the intended behavior without providing a clear way to opt out of this deduplication. Obviously the user could provide some extra headers or meaningless query string parameters to make the request look different, but this has the defaults swapped around.
默认情况下,不应对请求进行重复数据删除,因为我们无法确定用户的意图。允许用户清楚地表达其请求意图的一种安全方法是接受用户选择的标识符,而不是从请求内容中隐含地派生标识符。
By default, requests should not be deduplicated, as we cannot know for sure the user’s intent. And a safe way to allow a user to clearly express the intent of their request is to accept a user-chosen identifier, not deriving one implicitly from the request content.
最后,考虑到我们将在 26.3.5 节中学到的内容,这最终将不是一个永不重复工作的真实案例。由于缓存值几乎肯定会在某个时间点过期,因此这更像是一种速率限制机制,确保同一请求不会在某个时间段内执行两次。
Finally, given what we’ll learn in section 26.3.5, this ultimately will not be a true case of never duplicating work. Since cache values almost certainly expire at some point, this would act more as a rate limit mechanism, ensuring the same request wouldn’t be executed twice within some time period.
当我们讨论缓存的工作原理时,让我们简要讨论一下将缓存什么以及背后的基本原理这些选择。
While we’re on the topic of how caching works, let’s briefly discuss what will be cached and the rationale behind these choices.
在图 26.4,我们可以看到每个具有请求标识符的请求都会将结果存储在某处的缓存中。该过程首先检查所提供请求 ID 的缓存,如果存在值,我们将返回服务器实际处理请求时第一次缓存的结果。如果在缓存中未找到请求 ID,我们会处理请求,然后使用结果值更新缓存,然后根据请求将响应发送回用户。
In figure 26.4, we can see that every request that has a request identifier will have the result stored in a cache somewhere. The process begins by checking the cache for the provided request ID, and if a value is present we return the result that was cached the first time around when the request was actually processed by the server. If the request ID isn’t found in the cache, we process the request and then update the cache with the result value before sending the response back to the user as requested.
Figure 26.4 Sequence of caching responses for nonduplicate requests
这种模式似乎令人担忧的一件事是整个响应值都存储在缓存中。在这种情况下,存储需求会很快失去控制;毕竟,我们在技术上缓存了每个非幂等 API 调用的结果。如果这些反应本身就很大怎么办?例如,假设一个批量创建方法提供了 100 个资源。如果我们收到该请求并使用此模式进行请求去重,我们将需要缓存相当多的数据,而这只是一个请求。
One thing that might seem worrisome with this pattern is the fact that the entire response value is being stored in the cache. In this case, the storage requirements can grow out of control pretty quickly; after all, we’re technically caching the results of every non-idempotent API call. And what if these responses are quite large themselves? For example, imagine a batch create method with 100 resources provided. If we receive that request and are using this pattern for request deduplication, we’ll need to cache quite a bit of data, and that’s only a single request.
对于所有聪明的工程师来说,最重要的是,即使我们可以设计一个更复杂的系统来避免缓存所有这些数据,而是尝试重新计算响应而不再次处理请求,但这仍然不是一个好主意。归根结底,工程师调试和管理复杂系统的每小时费用远远超过在云中获得更多 RAM 或更大缓存集群的成本。因此,从长远来看,缓存响应只是一个安全的选择,因为你总是可以很快地投入更多的硬件来解决一个问题,但投入更多的大脑并不那么容易力量。
The bottom line, for all the clever engineers out there, is that even though we could design a more complicated system that avoids caching all this data and instead tries to recalculate the response without processing the request again, it’s still not a good idea. At the end of the day, an engineer’s hourly rate for debugging and managing a complex system far outweighs the costs for more RAM or a larger caching cluster in the cloud. As a result, caching the responses is just the safe bet in the long term, as you can always throw more hardware at a problem pretty quickly, but it’s not nearly as easy to throw more brain power.
隐在此要求中,缓存已处理请求的响应是一致性的关键问题。换句话说,缓存值很容易变得陈旧或过时,因为数据会随着时间的推移而更新,而这些更新不会反映在缓存值中。那么,问题是在请求重复数据删除时这是否重要。
Hidden in this requirement to cache the responses from requests that have been processed is a key question of consistency. In other words, cached values can very easily become stale or out of date as the data is updated over time and these updates are not reflected in the cached value. The question, then, is whether this is important when it comes to request deduplication.
为了更清楚地说明问题,让我们假设两个客户端(A 和 B)都在对资源执行更新,如图 26.5 所示。在这种情况下,让我们假设没有冲突需要担心。相反,让我们担心客户端 A 的连接状况不佳并且可能需要经常重试请求这一事实。
To illustrate the problem more clearly, let’s imagine that two clients (A and B) are both performing updates on a resource, shown in figure 26.5. And in this case, let’s assume that there are no conflicts to worry about. Instead, let’s worry about the fact that client A has a poor connection and may need to retry requests every so often.
Figure 26.5 It’s possible to see stale data when retrying requests.
在此示例中,虽然 A 和 B 都在更新资源并且在这些更新中没有冲突,但来自 A 的更新的响应在传输过程中丢失并且从未收到。因此,A 简单地决定使用与之前相同的请求标识符重试请求,以确保修改重复两次。在第二次尝试中,服务器以资源的缓存值作为响应,向 A 显示请求中最初设置的标题。但是有一个问题:现在的数据确实不是这样的!
In this example, while A and B are both updating a resource and having no conflicts in those updates, the response from A’s update is lost in transit and never received. As a result, A simply decides to retry the request using the same request identifier as before to ensure that the modifications are repeated twice. On this second attempt, the server responds with the cached value of the resource, showing A the title that was originally set in the request. There’s one problem though: that’s really not how the data looks right now!
一般来说,像这样的陈旧或不一致的数据可能是一个巨大的问题,但重要的是要记住这种模式的目的。主要目标是允许客户端重试请求而不会导致潜在危险的重复工作。该目标的一个关键部分是确保客户收到与第一次一切正常时相同的响应结果。因此,虽然这一系列事件(图 26.5)可能看起来有点奇怪,但它是完全合理和正确的。事实上,返回除此结果之外的任何其他内容可能会更加混乱和不可预测。换句话说,缓存绝对不应该随着底层数据的变化而保持最新,因为这会导致比我们在本文中看到的陈旧数据更加混乱案件。
In general, stale or inconsistent data like this can be an enormous problem, but it’s critical to recall the purpose of this pattern. The primary goal is to allow clients to retry requests without causing potentially dangerous duplication of work. And a critical part of that goal is to ensure that the client receives the same result in response that they would have if everything worked correctly the first time. As a result, while this sequence of events (figure 26.5) might look a bit strange, it’s completely reasonable and correct. As a matter of fact, it’d probably be more confusing and unpredictable to return anything else besides this result. In other words, the cache should absolutely not be kept up-to-date as the underlying data changes, as it would lead to even more confusion than the stale data we see in this case.
一个允许用户为请求选择自己的标识符的必然结果是他们通常不擅长。请求标识符选择不当的最常见结果之一是冲突:请求 ID 并不是真正随机的,最终会被多次使用。在我们的设计中,这可能会导致一些棘手的情况。
An inevitable consequence of allowing users to choose their own identifiers for requests is that they are generally bad at it. And one of the most common results of poorly chosen request identifiers is collisions: where a request ID is not really all that random and ends up being used more than once. In our design, this can lead to some tricky situations.
例如,让我们想象两个用户(A 和 B)与我们的 API 交互,连接非常好,但他们都不擅长选择请求 ID。正如我们在图 26.6 中所见,它们最终都选择了相同的请求 ID,带来了灾难性的(而且非常奇怪的)后果。
For example, let’s imagine two users (A and B) interacting with our API, with perfectly good connections, but they are both terrible at choosing request IDs. As we can see in figure 26.6, they end up each choosing the same request ID, with disastrous (and very strange) consequences.
Figure 26.6 Confusing results happen due to request identifier collisions.
不知何故,客户端 B 似乎试图创建User资源取而代之的是取回ChatRoom资源. 原因很简单:由于请求 ID 发生了冲突,API 服务器注意到了它并返回了之前缓存的响应。在这种情况下,事实证明响应是完全荒谬的,与客户端 B 试图做的事情无关。这显然是一个糟糕的场景,那么我们能做什么呢?
Somehow it seems that client B attempted to create a User resource and instead got back a ChatRoom resource. The cause of this is pretty simple: since there was a collision on the request ID, the API server noticed it and returned the previously cached response. In this case, it turns out that the response is completely nonsensical, having nothing to do with what client B is trying to do. This is clearly a bad scenario, so what can we do?
正确的做法是确保自上次我们看到同一标识符以来请求本身没有改变。换句话说,客户端 B 的请求也应该有相同的请求主体——否则,我们应该抛出错误并让客户端 B 知道他们显然犯了错误。为了让它工作,我们需要解决两件事,都显示在清单 26.2 中。
The correct thing to do is ensure that the request itself hasn’t changed since the last time we saw that same identifier. In other words, client B’s request should have the same request body as well—otherwise, we should throw an error and let client B know they’ve clearly made a mistake. To make this work, we’ll need to address two things, both shown in listing 26.2.
首先,当我们在执行还没有缓存值的请求后缓存响应值时,我们还需要存储请求主体本身的唯一指纹(例如,请求的 SHA-256 哈希)。其次,当我们查找请求并找到匹配项时,我们需要验证此(潜在)重复项上的请求正文是否与前一个匹配。如果它们匹配,那么我们就可以安全地返回缓存的响应。如果他们不这样做,我们需要返回一个错误,最好是409 ConflictHTTP 错误.
First, when we cache the response value after executing a request that doesn’t yet have a cache value, we need to also store a unique fingerprint of the request body itself (e.g., a SHA-256 hash of the request). Second, when we look up a request and find a match, we need to verify that the request body on this (potential) duplicate matches the previous one. If they match, then we can safely return the cached response. If they don’t, we need to return an error, ideally something along the lines of a 409 Conflict HTTP error.
Listing 26.2 Example method to update a resource with request identifiers
function UpdateChatRoom(req: UpdateChatRoomRequest): ChatRoom { if (req.requestId === undefined) { ❶ return ChatRoom.update(...); } const hash = crypto.createHash('sha256') .update(JSON.stringify(req)) .digest('hex'); const cachedResult = cache.get(req.requestId); if (!cachedResult) { ❷ const response = ChatRoom.update(...); cache.set(req.requestId, { response, hash }); ❸ return response; } if (hash == cachedResult.hash) { ❹ return cachedResult.response; } else { throw new Error('409 Conflict'); ❺ } }
❶ If there’s no request ID provided, simply perform the update and return the result.
❷ If there's no cached value for the given request ID, actually update the resource.
❸ Update the cache with the response and the hash value.
❹ If the hash matches, we can safely return the response.
❺ If the hash doesn’t match, throw an error.
通过使用这个简单的算法,每当我们看到一个已经被服务过的请求 ID 时,我们可以仔细检查它是否确实是同一请求的副本而不是冲突。这使我们能够在面对用户在选择真正独特的请求方面表现不佳的不幸常见情况时安全地返回响应身份标识。
By using this simple algorithm, whenever we see a request ID that has already been serviced, we can double-check that it really is a duplicate of the same request and not a collision. This allows us to safely return the response in the face of the unfortunately common scenario where users do a poor job of choosing truly unique request identifiers.
现在我们已经涵盖了该模式的大部分复杂部分,我们可以进入最后一个主题:将数据在缓存中保留多长时间。显然,在一个完美的世界中,我们可以永远保留所有数据,并确保在用户必须重试请求的任何时候,即使是在请求发出几年后,我们也可以像我们希望的那样返回缓存的结果当我们第一次回应时。不幸的是,数据存储需要花费金钱,因此在我们的 API 中是一种有限的商品。最终,这意味着我们必须决定要在请求缓存中保留多长时间。
Now that we’ve covered most of the complicated bits of this pattern, we can move onto one final topic: how long to keep data around in the cache. Obviously, in a perfect world, we could keep all data around forever and ensure that any time a user had to retry a request, even if it was several years after the request was made, we could return the cached result exactly as we would have when we first responded. Unfortunately, data costs money to store, and is therefore a limited commodity in our API. Ultimately, this means we have to decide on how long we want to keep things in the request cache.
通常,在发生故障后会相对较快地重试请求,通常是在原始请求后几秒钟。因此,缓存过期策略的一个好的起点是将数据挂起大约五分钟,但每次访问缓存值时都重新启动该计时器,因为如果我们有缓存命中,则意味着重试了请求。如果再次失败,我们希望为后续重试提供与第一次尝试相同的过期策略。
In general, requests are retried relatively soon after a failure occurs, typically a few seconds after the original request. As a result, a good starting point for a cache expiration policy is to hang onto data for around five minutes but restart that timer every time the cached value is accessed, because if we have a cache hit it means that the request was retried. If there is further failure, we want to give subsequent retries the same expiration policy as first attempts.
为了使这个决定更容易,有一些好消息。由于缓存,就其本质而言,会导致数据在一段时间后过期,无论我们决定什么,都可以在以后根据容量与流量的比值进行微调。换句话说,虽然五分钟是一个很好的起点,但这个决定非常容易根据用户行为、可用内存容量和成本。
To make this decision easier, there’s some good news. Since caching, by its very nature, results in data expiring after some amount of time, whatever we decide can always be fine-tuned later based on how much capacity there is compared to how much traffic there is. In other words, while five minutes is a good starting point, this decision is extraordinarily easy to reevaluate and adjust based on user behavior, memory capacity available, and cost.
这这种模式的最终 API 定义非常简单:只需添加一个字段。为了再次阐明模式的细节,显示了一个完整的示例序列在图 26.7。
The final API definition is quite simple for this pattern: just adding a single field. To clarify the details of the pattern once more, a full example sequence is shown in figure 26.7.
Listing 26.3 Final API definition
abstract class ChatRoomApi { @post("/chatRooms") CreateChatRoom(req: CreateChatRoomRequest): ChatRoom; } interface CreateChatRoomRequest { resource: ChatRoom; requestId?:string }
Figure 26.7 Sequence of events for request deduplication
在在这种模式下,我们使用一个简单的唯一标识符来避免在我们的 API 中重复工作。这最终是允许安全重试非幂等请求和 API 方法复杂性(以及我们的缓存要求的一些额外内存要求)之间的权衡。虽然某些 API 可能并不完全关心重复的请求场景,但在其他情况下它可能很关键——事实上,如此关键以至于请求标识符可能是必需的而不是可选的。
In this pattern, we use a simple unique identifier to avoid duplicating work in our APIs. This is ultimately a trade-off between permitting safe retries of non-idempotent requests and API method complexity (and some additional memory requirements for our caching requirements). While some APIs might not be all that concerned about duplicate request scenarios, in other cases it can be critical—so critical, in fact, that request identifiers might be required instead of optional.
归根结底,是否使用这种请求去重机制使某些方法复杂化的决定确实取决于场景。因此,根据需要添加它可能是有意义的,从一些特别敏感的 API 方法开始,并随着时间的推移扩展到其他。
At the end of the day, the decision of whether to complicate certain methods with this mechanism for request deduplication really does depend on the scenario. As a result, it likely makes sense to add it on an as-needed basis, starting with some particularly sensitive API methods and expanding over time to others.
Why is it a bad idea to use a fingerprint (e.g., a hash) of a request to determine whether it’s a duplicate?
Why would it be a bad idea to attempt to keep the request-response cache up-to-date? Should cached responses be invalidated or updated as the underlying resources change over time?
What would happen if the caching system responsible for checking duplicates were to go down? What is the failure scenario? How should this be protected against?
Why is it important to use a fingerprint of the request if you already have a request ID? What attribute of request ID generation leads to this requirement?
Requests might fail at any time, and therefore unless we have a confirmed response from the API, there’s no way to be sure whether the request was processed.
Request identifiers are generated by the API client and act as a way to deduplicate individual requests seen by the API service.
For most requests, it’s possible to cache the response given a request identifier, though the expiration and invalidation parameters must be thoughtfully chosen.
API services should also check a fingerprint of the content of the request along with the request ID in order to avoid unusual behavior in the case of request ID collisions.
API 可能会令人困惑。有时他们可能会混淆到不清楚给定 API 调用的结果是什么的地步。对于安全的方法,我们有一个简单的解决方案来计算结果:试一试。但是,对于不安全的方法,该解决方案显然行不通。在这个模式中,我们将探索一个特殊的validateOnly领域我们可以添加到请求接口,这将作为一种机制,通过它我们可以看到如果执行请求会发生什么,而无需实际执行请求。
APIs can be confusing. Sometimes they can be confusing to the point where it’s unclear what a result will be for a given API call. For safe methods, we have a simple solution to figure out the result: just give it a try. For unsafe methods, though, that solution will obviously not work. In this pattern, we’ll explore a special validateOnly field we can add to request interfaces, which will act as a mechanism by which we can see what would have happened if the request was executed, without actually executing the request.
即使是最简单的 API 也可能会有些混乱 — 毕竟,API 很复杂(否则不会有这样的书)。无论我们阅读文档多少次,仔细检查我们的源代码,调查授予我们的权限,以及审查我们将要发送到 API 的出站请求,我们很少在第一次尝试时就把所有事情都做对. 也许在下一次尝试中。或者那之后的那个。关键是会有很多次尝试,如果其中一些没有成功,或者成功了但方式错误也没关系。
Even the most straightforward APIs can be somewhat confusing—after all, APIs are complicated (otherwise there wouldn’t be a book like this). And no matter how many times we read the documentation, double-check our source code, investigate the permissions granted to us, and review the outbound requests we’re about to send to an API, we very rarely get everything right on the first attempt. Perhaps on the next attempt. Or the one after that. The key point is that there will be many attempts and it’s okay if some of them don’t succeed, or do succeed but in the wrong way.
当我们刚开始时,这种戳一下看看会发生什么的方法效果很好,但当涉及到生产系统时,它可能会导致严重的问题。这与大多数人在出现问题时将汽车送往机械师的原因是一样的:他们可以戳它,但这有损坏它的风险。它不是玩具车,而是上下班、学校或杂货店的重要工具。
While this method of poking and seeing what happens works quite well when we’re just getting started, it can cause serious problems when it involves a production system. This is sort of the same reason most people take their cars to the mechanic when somethings wrong: they could poke at it, but this runs the risk of breaking it. And it’s not a toy car, it’s a critical tool to get to and from work, school, or the grocery store.
起初,我们可以通过只研究不更改任何数据或有任何可怕副作用的安全方法来避免这种危险,但其他方法呢?例如,我们可能不想尝试名为DeleteAllDataAndExplode(). 并不是说我们不需要尝试这些方法;我们根本没有这样做的好方法。
At first, we can get around this danger by poking only at the safe methods that don’t change any data or have any scary side effects, but what about the others? For example, we probably don’t want to experiment with a method named DeleteAllDataAndExplode(). It’s not as though we have less of a need to experiment with these methods; we simply don’t have a good way of doing so.
当我们意识到在检查像我们是否有权执行给定 API 方法这样简单的事情时涉及多少人为错误时,这一点至关重要。例如,考虑我们要验证是否可以调用该DeleteAllDataAndExplode()方法的情况. 如图 27.1 所示,我们可以查看文档以查看需要哪些权限,然后检查我们使用哪些凭据发送 API 调用,然后在系统中检查这些凭据是否具有文档列出的权限。但是,如果依赖链中的任何地方出现错误怎么办?
This is critically important when we realize just how much human error is involved in checking something as simple as whether we have access to execute a given API method. For example, consider the case where we want to verify whether we can call the DeleteAllDataAndExplode() method. As shown in figure 27.1, we could look at the documentation to see what permissions are required, then check which credentials we’re using to send the API call, and then check in the system whether those credentials have the permission listed by the documentation. But what if there was a mistake anywhere along that chain of dependencies?
Figure 27.1 Requests might involve human error in all the various checks in the system.
归根结底,人类会犯错误,而计算机只会完全按照指示去做(即使我们可能打算以不同的方式指示它们)。因此,虽然我们可能有 99% 的把握一切都会顺利进行,但在我们尝试之前我们无法确定。必须有更好的方法来试验所有 API 方法——即使是危险的方法。
At the end of the day, humans make mistakes whereas computers just do exactly as they’re instructed to (even when we might mean to instruct them differently). So while we might be 99% sure that everything will go smoothly, we won’t know for certain until we try. There must be a better way to experiment with all API methods—even the dangerous ones.
自从我们的目标是允许用户获得对其 API 调用的预览响应,我们可以通过一个字段来实现这一点,该字段指定请求应仅被视为用于验证目的。当一个 API 方法发现一个请求将被验证而不是被执行时,它可以执行尽可能多的实际工作,而不会导致底层系统发生任何真正的变化。在某种程度上,指定为只需要验证的请求应该有点像在自动回滚且永不提交的事务中执行工作d.
Since our goal is to allow users to get preview responses to their API calls, we can accomplish this with a single field specifying that the request should be treated as for validation purposes only. When an API method sees that a request is to be validated and not executed, it can perform as much of the actual work as is feasible without causing any true changes to occur to the underlying system. In a way, requests specified as only needing validation should be a bit like executing the work inside a transaction that’s automatically rolled back and never committed.
Listing 27.1 Example of a standard create method supporting validation requests
interface CreateChatRoomRequest { resource: ChatRoom; validateOnly?: boolean; ❶ }
❶ We rely on a simple Boolean flag to indicate whether a request is for validation only.
这样做的原因很简单,用户应该得到尽可能多的验证。这意味着应该检查请求是否允许执行该操作,是否与现有数据冲突(例如,可能不满足唯一字段要求),是否检查引用完整性(例如,可能请求引用的另一个资源不符合要求)存在),以及对请求的任何其他服务器端形式的验证。一般来说,如果请求在实际执行时会抛出错误,那么在验证时也应该抛出相同的错误。
The reason for this is simply that users should get as much validation as possible. This means that requests should be checked for permission to perform the action, for conflicts with existing data (e.g., perhaps a unique field requirement would not be satisfied), for referential integrity (e.g., perhaps a request refers to another resource that doesn’t exist), and for any other server-side form of validation on the request. In general, if the request would throw an error when it was actually executed, it should throw that same error when being validated.
虽然勇敢,但这并不总是可能的。这可能是由于许多不同的原因(例如,与外部服务的连接或其他依赖项,如第 27.3.1 节所述),但目标保持不变:尝试使验证响应尽可能接近真实事物。在下一节中,我们将探讨应如何处理仅验证请求的所有细节,以及需要处理的各种棘手场景。解决。
While valiant, this is not always possible. This could be due to many different things (e.g., connections to external services or other dependencies, as discussed in section 27.3.1), but the goal remains the same: try to make validation responses as close to the real thing as possible. In the next section, we’ll explore all of the details of how validation-only requests should be handled as well as various tricky scenarios that need to be addressed.
作为validateOnly我们在清单 27.1 中看到,我们可以允许用户通过将字段设置为显式 Boolean来指定请求仅用于验证true。否则,默认情况下,该请求将只不过是一个常规请求。这个默认值很重要,因为如果我们选择相反的方式(默认总是只验证请求),我们会无意中削弱所有请求,使其无法在默认情况下进行实际工作。为了在这种情况下获得任何类似于正常行为的东西,我们总是需要设置一个标志,这对于任何 API 来说都可能是一个大错误。
As we saw in listing 27.1, we can allow a user to specify that a request is for validation only by setting the validateOnly field to an explicit Boolean true. Otherwise, by default, the request will be nothing more than a regular request. This default is important because if we chose the other way around (defaulting to always validate requests only), we’d be inadvertently crippling all requests from doing actual work by default. To get anything resembling normal behavior in this case, we’d always need to set a flag, which is likely a big mistake for any API.
由于目标是提供对请求响应的真实预览,因此验证请求应努力验证请求的尽可能多的方面,这可能涉及与其他远程服务的对话(只是不实际修改任何数据)。例如(如图 27.2 所示),我们可能会与访问控制服务对话以确定用户是否有权执行所请求的操作。如果需要担心参照完整性问题,我们可能会检查资源是否存在。我们可能会验证某些参数的正确性,例如确保字符串 SQL 查询的格式有效。
Since the goal is to provide a realistic preview of a response to a request, validation requests should strive to validate as many aspects of the request as possible, which may involve talking to other remote services (just not actually modifying any data). For example (as shown in figure 27.2), we might talk to an access control service to determine whether the user has access to perform the action requested. We might check that a resource exists if there are questions of referential integrity to worry about. We might validate some parameters for correctness, such as ensuring a string SQL query is in a valid format.
Figure 27.2 Validation requests might still connect to other internal components.
此外,对请求的响应应该代表对常规(非验证)请求的响应。这意味着如果请求由于任何原因导致错误,我们应该完全按照其他方式返回该错误。例如,如果我们无权执行请求,我们可能会收到403 ForbiddenHTTP 错误.
In addition, the response to the request should be representative of what a response to a regular (non-validation) request would be. This means that if the request would cause an error for any reason, we should return that error exactly as we would otherwise. For example, if we don’t have access to execute the request, we might get a 403 Forbidden HTTP error.
另一方面,如果请求成功,则响应应尽可能接近真实响应。这意味着应该填充可以为响应类型合理填写的任何字段。有些字段根本无法填写(例如,服务器生成的标识符),应留空或填写看起来很逼真(但肯定无效)的值。但归根结底,结果字段主要用于确认请求将成功执行,因此在响应中返回所有信息不应该是硬性要求。为此,用户应该执行真正的请求而不是验证请求。不言而喻,支持对某些方法而不是其他方法的验证请求是完全合理的——毕竟,
On the other hand, if the request would have been successful, the response should be as close to a real response as is possible. This means that whatever fields can be reasonably filled in for the response type should be populated. Some fields simply cannot be filled in (e.g., a server-generated identifier) and should be left blank or filled in with a realistic-looking (but certainly not valid) value. Ultimately though, the result fields are mainly intended to confirm that the request will execute successfully, so it shouldn’t be a hard requirement that all information is returned in the response. For that, users should execute a real request rather than a validation request. It also should go without saying that it’s perfectly reasonable to support validation requests on some methods and not others—after all, it simply might not make sense on some methods.
所有这一切中最重要的是任何验证请求都应该产生一个完全安全和幂等的方法。这意味着不应更改任何数据,不应以任何方式出现副作用,并且任何标记为“仅验证”的请求每次都应重新运行并获得相同的结果(假设系统中没有发生其他更改) .
The most important thing in all of this is that any validation request should result in a method that is completely safe and idempotent. That means that no data should be changed, no side effects should manifest in any way, and any requests marked as “validate only” should be re-runnable each time and get the same result (assuming no other changes are happening in the system).
当 API 方法是只读的但不一定免费时,这可能会让人感到困惑。例如,考虑一个查询大型数据仓库的 API 方法。从技术上讲,对大量数据运行 SQL 查询不会更改任何数据,但它肯定会花费很多钱。在某些情况下,成本与恰好与查询本身匹配的数据量有关。在这种情况下,即使它看起来像一个validateOnly参数可能是不必要的,它实际上是非常关键的。否则,用户如何确定他们的 SQL 查询是否仅包含有效语法?虽然我们可能无法验证查询的所有方面,但我们至少可以在查询无效的情况下返回错误结果。
This might get confusing when an API method is read-only but not necessarily free. For example, consider an API method to query a large data warehouse. Running a SQL query of a large set of data technically doesn’t change any data, but it certainly could cost quite a lot of money. And in some cases, the cost is related to the amount of data that happens to match the query itself. In this case, even though it might seem like a validateOnly parameter might be unnecessary, it’s actually quite critical. Otherwise, how can a user know for sure whether their SQL query contains only valid syntax? While we might not be able to verify all aspects of the query, we can at least return an error result in the case where the query is invalid.
综上所述,不幸的事实是请求的某些方面会导致我们违反这些关于始终完全安全和幂等的规则,因此我们有一个大问题要回答:在提供完全忠实的行为时我们会做什么该方法的结果会导致违规,例如非幂等方法?在下一节中,我们将研究外部依赖项以及如何在验证请求的上下文中使用它们。
With all that said, the unfortunate truth is that some aspects of a request will cause us to violate these rules about always being completely safe and idempotent, so we have a big question to answer: what do we do when providing full fidelity with the behavior of the method results in a violation such as a non-idempotent method? In the next section, we’ll look at external dependencies and how to work with them in the context of validation requests.
一对验证请求提出问题的明显地方是对外部服务或库的依赖。原因很简单:由于我们无法控制这些外部性,因此我们无法解释我们只是出于验证目的与它们进行通信。例如,如果我们的 API 每当ChatRoom资源更新后,我们可以通过向他们发送测试电子邮件(如图 27.3 所示)来验证我们是否可以向特定收件人发送电子邮件,但这对于验证请求来说是一个相当大的副作用。因此,我们必须做出选择:避免验证某些方面或违反我们关于副作用、安全性和幂等性的规则。
One obvious place that presents a problem to validating requests is dependencies on external services or libraries. The reason is simple: since we don’t control those externalities, we can’t explain that we’re only communicating with them for validation purposes. For example, if our API sends email messages whenever ChatRoom resources are updated, we could validate whether we can send email to a particular recipient by sending them a test email (shown in figure 27.3), but this would be a pretty big side effect for a validation request. As a result, we have to make a choice: avoid validating certain aspects or break our rules about side effects, safety, and idempotence.
Figure 27.3 External dependencies make validation requests difficult or impossible.
幸运的是,选择很简单:遵守规则。如果外部依赖项支持这样的验证请求,那么我们可以很高兴地依赖它们为我们提供对下游服务进行真正验证所需的信息;但是,如果这不可用,我们应该完全跳过它。拥有安全、幂等且无副作用的验证过程的重要性远比验证每一个小问题重要得多。
Luckily, the choice is simple: stick to the rules. If an external dependency supports validation requests like this, then we can happily rely on those to provide us with the information we need to do true validation of this downstream service; however, if this is not available, we should skip it entirely. The importance of having a safe, idempotent, and side effect–free validation process is far more important than validating each and every tiny issue.
这可能意味着当外部资源显然不支持必要的用例时,需要花费一些额外的时间来执行其他相关验证。例如,虽然我们可能无法测试使用外部服务发送电子邮件,但我们可能会执行某种电子邮件地址格式验证,以至少捕获明显的无效数据(例如,没有“ @”符号的电子邮件)。它当然不是一个完美的替代品,但总比不执行验证要好全部。
This might mean taking a bit of extra time to perform other related validation when it’s clear that an external resource will not support the use case necessary. For example, while we might not be able to test sending an email with an external service, we can likely perform some sort of email address format validation to at least catch obvious invalid data (e.g., an email with no “@” symbol present). It’s certainly not a perfect replacement, but it is better than performing no validation at all.
甚至当我们控制整个系统并且不维护任何外部依赖性时,仍然有可能为验证请求提供有用的预览响应可能会造成混淆。考虑一个特殊的聊天室彩票功能的例子,它有一个随机返回特殊获胜响应的方法(如图 27.4 所示)。假设请求有效,当我们向该方法发送验证请求时,响应应该是什么?或者如果我们有一些更具确定性而不是纯粹随机性的东西,但仍然依赖于聊天室中其他人的行为怎么办?例如,如果不是随机选择获胜者,而是更像是一场广播节目比赛,每 100 次调用 API 方法都会产生一个特殊响应,那会怎么样?这会改变我们对可能的回应的看法吗?
Even when we control the entire system and maintain no external dependencies, there’s still a chance that providing a useful preview response to a validation request can be confusing. Consider an example of a special chat room lottery feature, with a method that returns a special winning response randomly (shown in figure 27.4). Assuming the request is valid, what should the response be when we send a validation request to this method? Or what if we had something a bit more deterministic rather than purely random, but still dependent on the behavior of others in a chat room? For example, what if instead of a randomly selected winner it’s more like a radio show contest where every 100th call to an API method results in a special response? Does this change our opinion on the possible response?
Figure 27.4 Example lottery requests with internal random dependencies
由于不可避免地会出现我们不能省略信息而必须做出选择的情况(例如,一个布尔字段说明我们是否中了彩票),记住这种验证方法的目标很重要。首先,我们需要验证请求并返回可能出现的任何潜在错误。其次,假设请求确实有效,我们应该返回一个看似合理的结果,该结果不一定是真实的,而是代表现实。
Since there will inevitably be cases where we cannot omit information and instead have to make a choice (e.g., a Boolean field saying whether we won the lottery), it’s important to remember the goals of this validation method. First, we need to validate a request and return any potential errors that might arise. Second, assuming the request is indeed valid, we should return a plausible result that is not necessarily real but is instead representative of reality.
如果我们遵循这些指导方针,我们之前的问题的答案就会简单得多:我们应该随时返回中奖的彩票结果,而在其他时候返回失败的结果。这些中奖的百分比不一定需要反映真实的彩票赔率,只要结果是真实的就可以合理地返回。同样的事情也适用于更具确定性的无线电竞赛风格:有时我们可能会赢,有时我们可能不会,所以响应值可以是其中之一;两者都是现实的代表。简而言之,任何可以作为真实响应传递的内容都应被视为可接受的验证响应要求。
If we follow these guidelines, the answers to our earlier questions are much more straightforward: we should feel free to return a winning lottery result some of the time and a losing result other times. The percentages of these wins do not necessarily need to be reflective of the true lottery odds, so long as the result is one that could plausibly have been returned if the result had been real. The same thing goes for the more deterministic radio contest style: sometimes we might win, and others we might not, so the response value can be either one; both are representative of reality. In short, anything that is passable as a real response should be considered an acceptable response for a validation request.
在在这种情况下,API 定义本身相当简单:一个简单(可选)的布尔字段,允许用户指定请求仅用于验证,从而使请求完全安全和幂等的。
In this case, the API definition itself is fairly simple: a simple (optional) Boolean field that allows users to specify that a request is for validation only, thereby making the request completely safe and idempotent.
Listing 27.2 Final API definition
abstract class ChatRoomApi { @post("/chatRooms") CreateChatRoom(req: CreateChatRoomRequest): ChatRoom; } interface CreateChatRoomRequest { resource: ChatRoom; validateOnly?: boolean; }
在一般来说,验证请求是对一个烦人问题的简单回答。通过提供一个简单的标志,这些用户可以在面对 API 中所有细节带来的大量复杂性时获得一定程度的确定性。也就是说,它们很少是必需品,更多的是方便,主要是对用户而言。
In general, validation requests are a simple answer to a nagging problem. By providing a simple flag, these users can get some amount of certainty in the face of a massive amount of complexity that comes with all of the details present in APIs. That said, they’re rarely a hard necessity and much more of a convenience, primarily for users.
虽然用户是获得最大价值的人,但 API 本身仍然有好处。例如,在 API 方法昂贵的情况下(无论是金钱上的、计算上的,还是两者兼而有之),如果这些费用没有转嫁给用户,支持验证请求肯定会最大限度地减少浪费,原因很简单,因为用户可以表达他们的意图来简单地验证一个请求,从不执行它。
While users are the ones gaining the most value, there are still benefits to the APIs themselves. For example, in cases where API methods are expensive (either monetarily, computationally, or both), if that expense is not passed onto the user, supporting validation requests will certainly minimize waste for the simple reason that users can express their intent to simply validate a request and never execute it.
Why should we rely on a flag to make a request “validate only” rather than a separate method that validates requests?
Imagine an API method that fetches data from a remote service. Should a validation request still communicate with the remote service? Why or why not?
Does it ever make sense to have support for validation requests on methods that never write any data?
Why is it important that the default value for the validateOnly flag is false? Does it ever make sense to invert the default value?
Some API requests are sufficiently dangerous that they merit supporting a way for users to validate the request before actually executing it.
支持此功能的 API 方法应提供一个简单的布尔字段 ( validateOnly),指示请求仅被验证而不实际执行。这些请求应该被认为是安全的,不应该对底层系统产生任何影响。
API methods that support this functionality should provide a simple Boolean field (validateOnly) indicating that a request is to be validated only and not actually executed. These requests should be considered safe and should not have any effect on the underlying system.
通常会出现支持验证请求的 API 方法与外部服务进行交互的场景。在这些情况下,这些方法应尽其所能验证这些外部性,承认无法验证某些方法的安全性方面。
There will often be scenarios where API methods supporting validation requests will interact with external services. In these cases, these methods should validate those externalities to the best of their ability, acknowledging that it is not possible to validate safety for some aspects.
尽管资源随时间变化,但我们通常会丢弃过去可能发生的任何变化。换句话说,我们只存储资源现在的样子,而完全忽略资源在进行更改之前的样子。这种模式提供了一个框架,我们可以通过它跟踪单个资源随时间的多个修订,从而保留历史记录并启用高级功能,例如回滚到以前的修订。
Though resources change over time, we typically discard any changes that might have occurred in the past. In other words, we only ever store what the resource looks like right now and completely ignore how the resource looked before changes were made. This pattern provides a framework by which we can keep track of multiple revisions of a single resource over time, thereby preserving history and enabling advanced functionality such as rolling back to a previous revision.
到目前为止,我们在 API 中与资源的所有交互都只关注资源的当前状态,而忽略了以前的状态。虽然我们偶尔会考虑一致性等主题(例如,当我们查看在通过列表分页时资源发生变化时会发生什么),但关于资源总是有一个很大的假设:它们只存在于一个时间点,而那个时间是现在。
So far, all of our interactions with resources in an API have focused exclusively on the present state of the resource and ignored previous states. While we do occasionally consider topics like consistency (e.g., when we looked at what happens as resources change while paginating through a list), there’s always been a big assumption about resources: they only exist at a single point in time, and that time is right now.
到目前为止,这一假设对我们很有帮助,但某些资源一次需要的快照不止一个快照的情况并不少见。例如,我们经常跟踪 Microsoft Word 或 Google 文档中的修订历史记录。同样,如果我们的 API 涉及代表合同、采购订单、法律文件甚至广告活动的资源,那么用户可能会从能够随着时间的推移查看资源的历史记录中受益并不是一个疯狂的想法。这意味着,如果出现问题,则更容易诊断出是哪个变化引起的。
This assumption has served us well so far, but it’s not uncommon for certain resources to require a bit more than just one snapshot at a time. For example, we often keep track of revision history in a Microsoft Word or Google document. Likewise, if our API involves resources that represent contracts, purchase orders, legal filings, or even advertising campaigns, it’s not a crazy thought that users might benefit from being able to view the history of the resource over time. This means that if a problem were to occur it would be much easier to diagnose which change was the cause.
此模式的目标是提供一个框架,该框架可以为单个资源存储多个修订版,并允许其他更复杂的功能,例如删除不再需要的修订版或将资源回滚到先前的修订版,从而有效地撤消先前的更改.
The goal of this pattern is to provide a framework that enables storing multiple revisions for a single resource and also allow other more complex functionality such as deleting revisions that aren’t necessary anymore or rolling a resource back to a previous revision, effectively undoing previous changes.
到解决这些问题,这个模式将依赖于一个叫做资源修订的新概念。资源修订只是资源的快照,它标有唯一标识符并带有创建时间的时间戳。这些修改虽然本质上很简单,但提供了很多有趣的功能。
To solve these problems, this pattern will rely on a new concept called a resource revision. A resource revision is simply a snapshot of a resource that’s labeled with a unique identifier and timestamped with the moment at which it’s created. These revisions, though simple in nature, provide quite a lot of interesting functionality.
例如,如果我们可以浏览给定资源的所有修订版并直接检索单个修订版,那么从技术上讲,我们已经获得了回顾过去并查看资源随时间演变的能力。更重要的是,我们可以通过回滚到特定修订来撤消之前的更改,使资源看起来与先前修订时完全一样。
For example, if we can browse all revisions of a given resource and retrieve individual revisions directly, we’ve technically gained the ability to look into the past and see the evolution of a resource over time. More importantly, we can undo previous changes by rolling back to a specific revision, making a resource look exactly as it did at the time of a prior revision.
但是资源修订是什么样的呢?我们是否必须在 API 的生命周期内保持两个独立的接口同步?幸运的是,答案是否定的。资源修订根本不是一个单独的界面;相反,它是通过简单地向现有资源添加两个新字段而产生的概念:修订标识符和创建此修订时拍摄快照的时间戳。
But what does a resource revision look like? Will we have to keep two separate interfaces synchronized over the lifetime of an API? Luckily the answer is no. A resource revision is not a separate interface at all; instead it’s a concept brought about by simply adding two new fields to an existing resource: a revision identifier and a timestamp of when the snapshot was taken to create this revision.
Listing 28.1 Adding support for revisions to a Message resource
interface Message { id: string; content: string; // ... revisionId: string; ❶ revisionCreateTime: Date; ❶ }
❶通过添加两个字段,我们现在可以用相同的接口表示资源的多个修订版。
❶ By adding two fields, we can now represent multiple revisions of a resource with the same interface.
简而言之,这是通过存储具有相同标识符的多个记录来实现的(id); 但是,这些记录中的每一个都有不同的修订标识符 ( revisionId) 并代表资源在某个时间点的查看情况。现在,当我们通过标识符检索资源时,结果将是恰好是最新版本的资源修订版。从某种意义上说,资源标识符成为最新资源修订版的别名,“最新”被定义为该revisionCreateTime字段具有最新值的修订版.
In short, this works by storing multiple records with the same identifier (id); however, each of these records has a different revision identifier (revisionId) and represents the resource as it looked at some point in time. And now, when we retrieve a resource by its identifier, the result will be a resource revision that just happens to be the latest one. In a sense, the resource identifier becomes an alias of sorts for the latest resource revision, and “latest” is defined as the revision with the most recent value for the revisionCreateTime field.
然而,一如既往,有许多关于细节的问题需要回答。首先,我们如何决定何时创建新修订版?它应该是自动的还是需要特定的用户干预?我们如何实现列出修订或检索特定修订资源的功能?那么回滚到以前的修订版呢?在下一节中,我们将探讨所有这些问题细节。
As always, however, there are many questions about the details that need answering. First, how do we decide when to create a new revision? Should it be automatic or require specific user intervention? How do we implement the functionality to list revisions or retrieve a resource at a specific revision? And what about rolling back to a previous revision? In the next section, we’ll go into all of these questions in detail.
资源修订如何工作?正如我们所见,要使资源可修改,需要两个新字段,但仅此而已吗?当然不。我们需要探索一大堆额外的主题。这包括我们需要创建的各种自定义方法以及我们需要修改以适应此模式的现有标准方法。简而言之,我们需要为我们想要支持的每个新行为定义新的 API 方法(例如,检索单个资源修订、列出修订、回滚到以前的修订等)。
How do resource revisions work? As we’ve seen, there are two new fields necessary to make a resource revisable, but is that all? Definitely not. There is a whole pile of additional topics we need to explore. This includes a variety of custom methods we need to create and existing standard methods we need to modify to fit with this pattern. In short, we need to define new API methods for every new behavior we want to support (e.g., retrieving an individual resource revision, listing revisions, rolling back to a previous revision, etc.).
在我们可以做任何这些之前,我们需要探索这个新的修订标识符字段中到底有什么以及它是如何工作的。在下一节中,我们将开始深入研究这种模式,确定什么是好的修订标识符,以及它与我们典型的资源标识符有何不同。
Before we can do any of that, we need to explore what exactly will go into this new revision identifier field and how it will work. In the next section, we’ll start digging into this pattern by deciding what makes for a good revision identifier and how it might differ from our typical resource identifiers.
什么时候在选择修订标识符时,有几个选项。首先,我们可以使用计数,有点像递增的版本号(1、2、3、4、...)。另一种选择是使用基于时间的标识符,例如简单的时间戳值(例如,1601562420)。另一种选择是使用完全随机的标识符,这取决于我们在第 6 章中学到的原则。
When it comes to choosing a revision identifier, there are a few options. First, we could go with a counting number, sort of like an incremented version number (1, 2, 3, 4, . . .). Another option is to use a time-based identifier like a simple timestamp value (e.g., 1601562420). And yet another option is to use a completely random identifier, relying on the principles we learned about in chapter 6.
前两个选项(计数和时间戳)暗示了标识符中修订的时间顺序,与字段分开revisionCreateTime,这可能会有点问题。如果我们使用递增的修订号,如果曾经删除过修订(请参阅第 28.3.6 节),这可能会造成混淆,因为这会在修订历史中留下空白(例如,1、2、4)。由于删除某些内容的目的是删除它的所有痕迹,因此对修订字符串使用递增数字当然不是理想的,因为它会留下一个空隙,使得修订被删除很明显。
These first two options (counting numbers and timestamps) imply a chronological ordering of revisions in their identifier, separate and apart from the revisionCreateTime field, which can become a bit problematic. If we use incremental revision numbers, it might be confusing if a revision is ever deleted (see section 28.3.6), as this would leave a gap in the revision history (e.g., 1, 2, 4). Since the goal of deleting something is to remove all traces of it, using incremental numbers for the revision string is certainly not ideal as it leaves a gap, making it obvious that a revision was deleted.
另一方面,时间戳不受此问题的影响,因为它们传达了时间顺序,并容忍数据中的间隙。使用时间戳标识符,我们反而需要担心标识符上的冲突。我们不太可能遇到这个问题(通常只有在对系统的并发访问极高的情况下才有可能发生),但这并不意味着我们可以完全解决这个问题。换句话说,如果没有更好的选择,那么时间戳是一个很好的折衷方案;然而,有一个更好的选择符合第 6 章探讨的原则:随机标识符。
Timestamps, on the other hand, are immune to this problem, as they convey chronological ordering with a tolerance for gaps in the data. With timestamp identifiers, we instead need to worry about collisions on identifiers. It is very unlikely we’ll run into this issue (typically only likely to occur in cases of extremely high concurrent access to the system), but this doesn’t mean we can write off the problem entirely. In other words, if there are no better options, then timestamps are a fine compromise; however, there is a better option that adheres to the principles explored in chapter 6: a random identifier.
与其他两个选项不同,随机标识符只有一个用途:它是一个不透明的字节块,唯一标识单个修订版。但是修订标识符应该与资源标识符完全相同吗?虽然我们应该选择在整个 API 中一致地使用标识符,但在修订的情况下有一个值得探讨的问题:我们需要多大的标识符?
Unlike the other two options, a random identifier serves one and exactly one purpose: it’s an opaque chunk of bytes that uniquely identifies a single revision. But should revision identifiers be completely identical to resource identifiers? While we should opt to use identifiers consistently throughout an API, there is one question worth exploring in the case of revisions: how big do we need the identifier to be?
虽然 Crockford 的 Base32 格式(120 位)中的 25 个字符的标识符在创建数十亿甚至更多资源时可能至关重要,但问题是我们是否真的需要支持与给定类型的资源一样多的单个资源修订。一般来说,我们的修订比资源少得多,所以我们几乎肯定不需要完整的 120 位密钥空间。相反,依赖 13 个字符的标识符(60 位)作为 64 位整数提供的相同密钥空间大小的近似值可能是有意义的。
While a 25-character identifier in Crockford’s Base32 format (120 bits) might be critical when creating billions of resources and beyond, the question is whether we really need to support as many revisions of a single resource as we do resources of a given type. Generally, we have far fewer revisions than we do resources, so we almost certainly don’t need the full 120-bit key space. Instead, it probably makes sense to rely on a 13-character identifier (60 bits) as a close approximation for the same key space size provided by a 64-bit integer.
另一个需要考虑的问题是依赖标识符中的校验和字符是否仍然有意义,如第 6.4.4 节所述。虽然它看起来无关紧要,但请记住,校验和字符的目的是区分不存在的东西(即没有基础数据的有效标识符)和永远不可能存在的东西(即完全无效的标识符) . 按理说,正如对资源进行这种区分很重要一样,对资源的单个修订版也同样重要。因此,我们几乎肯定应该保留资源修订标识符的校验和字符部分。
Another question to consider is whether it still makes sense to rely on a checksum character in the identifier, as described in section 6.4.4. While it might seem extraneous, remember that the goal of the checksum character is to distinguish between something not being present (i.e., a valid identifier with no underlying data) and something not able to ever have been present (i.e., an invalid identifier entirely). It stands to reason that just as it’s important to make this distinction for a resource, it’s equally important for a single revision of a resource. As a result, we should almost certainly keep the checksum character part of a resource revision identifier.
我们可以重用第 6 章中显示的大部分相同代码来生成标识符,但标识符更短。
We can reuse much of the same code shown in chapter 6 to generate an identifier but with a shorter identifier.
Listing 28.2 Example code to generate random resource revision identifiers
const crypto = require('crypto'); const base32Decode = require('base32-decode'); function generateRandomId(length: number): string { const b32Chars = '012345689ABCDEFGHJKMNPQRSTVWXYTZ'; ❶ let id = ''; for (let i = 0; i < length; i++) { let rnd = crypto.randomInt(0, b32Chars.length); id += b32Chars[rnd]; } return id + getChecksumCharacter(id); ❷ } function getChecksumCharacter(value: string): string { const bytes = Buffer.from( base32Decode(value, 'Crockford')); ❸ const intValue = BigInt(`0x${bytes.toString('hex')}`); ❹ const checksumValue = Number(intValue % BigInt(37)); ❺ const alphabet = '0123456789ABCDEFG' + ❻ 'HJKMNPQRSTVWXYZ*~$=U'; return alphabet[Math.abs(checksumValue)]; }
❶ Here we generate an ID by randomly choosing Base32 characters.
❷ Finally, we return the ID along with a calculated checksum character.
❸ We start by decoding the Base32 string into a byte buffer.
❹ Then we convert the byte buffer into a BigInt value.
❺ Calculate the checksum value by determining the remainder after dividing by 37.
❻这里我们依赖 Crockford 的 Base32 校验和字母表
❻ Here we rely on Crockford’s Base32 checksum alphabet
现在我们已经复习了如何为修订生成唯一标识符,让我们看看这些修订究竟是如何产生的存在。
Now that we’ve had a refresher on generating unique identifiers for revisions, let’s look at how exactly these revisions come into existence.
明显地,能够识别修订很重要,但它基本上是无用的,除非我们首先知道这些修订是如何创建的。在创建新修订时,我们有两种不同的选择可供选择:显式创建修订(用户必须明确要求创建新修订)或隐式创建(无需任何特定干预自动创建新修订) .
Obviously, being able to identify revisions is important, but it’s basically useless unless we know how exactly these revisions get created in the first place. When it comes to creating new revisions, we have two different options to choose from: create revisions explicitly (where users must specifically ask for a new revision to be created) or create them implicitly (where new revisions are automatically created without any specific intervention).
虽然这些策略中的每一个都是完全可以接受的,但重要的是在支持资源修订的所有资源中使用相同的策略,因为如果我们在不同的资源中使用不同的修订策略,它通常会导致潜在的混淆,用户期望一种策略并感到惊讶当他们的期望被打破时。因此,应尽可能避免混合和匹配,并在存在使用不同策略的关键原因的情况下明确记录。
While each of these strategies is perfectly acceptable, it’s important that the same strategy be used across all resources that support resource revisioning because if we use different revision strategies across different resources, it can often lead to potential confusion where users expect one strategy and are surprised when their expectations are broken. As a result, mixing and matching should be avoided if at all possible and clearly documented in the cases where there’s a critical reason for using a different strategy.
所有这些都有一个警告。无论我们选择哪种策略,如果资源支持修订,则在首次创建该资源时,它必须填充该revisionId字段无论。否则,我们最终可能会得到实际上不是修订版的资源,这会导致我们在以下部分中探讨的交互模式出现很多问题。
There is one caveat to all of this. Regardless of which strategy we choose, if a resource supports revisions, when that resource is first created it must populate the revisionId field no matter what. Without this, we might end up with resources that aren’t actually revisions, which would cause quite a few problems with the interaction patterns that we explore in the following sections.
让我们通过查看隐式创建的工作原理来开始探索创建修订的不同机制。
Let’s start exploring the different mechanisms for creating revisions by looking at how implicit creation works.
这在支持此功能的任何 API 中创建资源修订的最常见机制是隐式执行。这只是意味着,API 本身决定何时创建新修订版,而不是用户专门指示 API 创建新修订版。这并不是说用户无法影响修订的创建时间。例如,创建新修订的最常见机制是让 API 在每次存储在资源中的数据发生变化时自动执行此操作,如图 28.1 所示。事实上,有许多现实世界的系统依赖于这种机制,例如 Google Docs 的修订历史记录或 GitHub 的问题跟踪,它们都跟踪有问题的资源(文档或问题)的所有历史记录。
The most common mechanism for creating resource revisions in any API that supports this functionality is to do so implicitly. This simply means that rather than a user specifically instructing the API to create a new revision, the API itself decides when to do so. This isn’t to say that users have no ability to influence when a revision is created. For example, the most common mechanism for creating new revisions is to have the API do so automatically every time the data stored in a resource changes, shown in figure 28.1. In fact, there are many real-world systems that rely on this mechanism, such as Google Docs’ revision history or GitHub’s issue tracking, both of which keep track of all history of the resource in question (either a document or an issue).
Figure 28.1 Flow diagram of implicitly creating revisions when updating.
虽然这是最常见且肯定是最安全的选项(保留最多的历史记录),但还有许多其他隐式修订跟踪策略可用。例如,服务可能不会为每次修改都创建一个新修订,而是按计划执行此操作,即在每天结束时创建一个新修订,只要已经进行了一些更改,或者可能会跳过一定数量的更改更改,每三次修改后创建一个新修订版,而不是每个单独的修改。
While this is the most common and certainly the safest option (preserving the most history), there are many other implicit revision-tracking strategies available. For example, rather than creating a new revision for every modification, a service might do so on a schedule, where a new revision is created at the end of each day so long as some changes have been made, or perhaps skipping a certain number of changes, creating a new revision after every third modification rather than each individual modification.
此外,我们可以根据里程碑而不是间隔进行修订。这意味着我们可以在每次修改单个特定字段或执行特定自定义方法时创建一个修订,而不是每次更新资源时都创建一个新修订。例如,也许我们为BlogPost资源创建新的修订版仅当自定义PublishBlogPost()方法被执行。
Further, we can base revisions on milestones rather than intervals. This means that rather than creating a new revision each time a resource is updated, we might create a revision every time a single specific field is modified or perhaps whenever a specific custom method is executed. For example, perhaps we create new revisions for a BlogPost resource only when the custom PublishBlogPost() method is executed.
所有这些都是完全可以接受的,尽管就应该如何创建修订规定一个单一的千篇一律的答案是很可爱的,但这在这里根本不可能。原因很简单:每个 API 都是不同的,并且具有不同的业务或以产品为中心的约束会产生不同的最适合创建资源修订的策略。
All of these are perfectly acceptable, and as lovely as it’d be to prescribe a single one-size-fits-all answer to how revisions should be created, that’s simply not possible here. The reason is simple: each API is different and with different business or product-focused constraints come different best-fit strategies for creating resource revisions.
一个好的通用指南是宁可保留更多的修订而不是更少的修订(我们在第 28.3.7 节探讨了节省空间的技术),并且最简单和最可预测的策略之一是每次资源时简单地创建新的修订被修改。这种策略易于理解和使用,并且对于大多数用户需要依赖资源的情况产生了非常有用的结果修订。
A good general guideline is to err on the side of keeping more revisions rather than fewer (we explore space-saving techniques in section 28.3.7), and one of the simplest and most predictable strategies is to simply create new revisions each time a resource is modified. This strategy is easy to understand and use and creates a very useful result for most cases where users need to rely on resource revisions.
在然而,在许多情况下,隐含地创建修订可能是完全没有必要的,而且过于浪费。这使我们想到了一个明显的替代方案:允许用户使用特殊的自定义方法明确说明他们何时想要创建新的资源修订版,如图 28.2 所示。这种显式机制允许用户准确控制何时创建修订,将决定修订创建策略的责任从 API 本身推回到用户身上。
In many cases, however, implicitly creating revisions might be completely unnecessary and overly wasteful. This leads us to an obvious alternative: allow users to explicitly state exactly when they want to create a new resource revision using a special custom method, shown in figure 28.2. This explicit mechanism allows a user to control exactly when revisions are created, pushing the responsibility of deciding on a policy for revision creation from the API itself back onto the user.
Figure 28.2 Flow diagram of explicitly creating resource revisions
为此,我们可以创建一个新的自定义方法,它在很多方面都类似于标准的创建方法。与在现有父资源下创建新资源不同,此自定义创建修订方法具有单一职责:拍摄当时存在的修订快照,并创建一个新修订来表示该快照。这个新修订应该有一个随机生成的标识符(如第 28.3.1 节所述),并且修订的创建时间戳应该设置为修订最终持久化的时间。
To do this, we can create a new custom method that, in many ways, resembles the standard create method. Rather than creating a new resource under an existing parent, this custom create revision method has a single responsibility: take a snapshot of the revision as it exists at that exact moment and create a new revision to represent that snapshot. This new revision should have a randomly generated identifier (as described in section 28.3.1), and the creation timestamp of the revision should be set to the time at which the revision is finally persisted.
Listing 28.3 API definition for creating a resource revision
abstract class ChatRoomApi { @post("/{id=chatRooms/*/messages/*}:createRevision") CreateMessageRevision(req: CreateMessageRevisionRequest): ❶ Message; } interface Message { id: string; content: string; // ... revisionId: string; ❷ revisionCreateTime: Date; ❷ } interface CreateMessageRevisionRequest { id: string; ❸ }
❶自定义的create revision 方法创建一个新的revision 并返回新创建的revision(也就是设置了特殊字段的资源)。
❶ The custom create revision method creates a new revision and returns the newly created revision (which is just the resource with special fields set).
❷我们可以通过添加我们在 28.2 节中学到的两个字段来使 Message 资源可修改。
❷ We can make the Message resource revisable by adding the two fields we learned about in section 28.2.
❸ The method needs to know only the identifier of the resource.
现在我们已经了解了创建新资源修订的方法,让我们看看我们如何与这些修订进行实际交互,首先我们如何通过检索具体的修订。
Now that we’ve seen the ways in which we can create new resource revisions, let’s look at how we can actually interact with those revisions, starting by how we can look back in time by retrieving specific revisions.
作为我们在 28.2 节中了解到,资源修订版提供的最有价值的功能之一是能够回顾和读取过去出现的资源数据。但这引出了一个有趣的问题:我们究竟如何要求资源的过去修订版?从技术上讲,修订标识符(第 28.3.1 节)存储为资源本身的一个字段;那么我们应该依靠带有特殊过滤器的标准列表方法吗?虽然这在技术上可行,但使用标准列表方法检索单个资源(而不是为了浏览资源的主要目的)确实非常麻烦并且感觉有点奇怪。幸运的是,有一个更好的方法来解决这个问题,涉及两部分。
As we learned in section 28.2, one of the most valuable features provided by resource revisions is the ability to look backward and read the data of a resource as it appeared in the past. But this leads to an interesting question: how exactly do we ask for a past revision of a resource? Technically, the revision identifier (section 28.3.1) is stored as a field on the resource itself; so should we rely on the standard list method with a special filter? While that technically would work, it certainly is quite cumbersome and feels a little odd to use the standard list method to retrieve a single resource (and not for its main purpose of browsing through resources). Luckily, there’s a much better way to go about this, involving two pieces.
首先,由于检索单个项目绝对是标准 get 方法的责任,因此该机制当然应该依赖于扩展该方法以支持这种新的面向修订的用例。其次,我们需要一种清晰而简单的方法来在请求中向方法提供修订标识符。为此,我们可以依靠一个新的特殊分隔符将整个资源标识符分为两部分:资源 ID 和修订 ID。为此,我们将依赖“ @”符号作为分隔符,得到完整的资源修订标识符,例如/chatRooms/1/messages/2@1234表示给定Message资源的修订 1234.
First, since retrieving single items is definitively the responsibility of the standard get method, this mechanism should certainly rely on extending that method to support this new revision-oriented use case. Second, it follows that we’ll need a clear and simple way of providing the revision identifier in the request to the method. To do this, we can rely on a new special separator character to divide the entire resource identifier into two pieces: the resource ID and the revision ID. For this purpose, we’ll rely on the “@” symbol as the divider, leading to complete resource revision identifiers such as /chatRooms/1/messages/2@1234 to represent revision 1234 of the given Message resource.
使用这个特殊的分隔符的好处非常好:标准的 get 方法根本不需要改变。由于我们只是发送一个稍微更详细的标识符,我们可以依赖id标准 get 请求中的相同字段,并允许 API 服务将其解释为给定修订版的资源。
The benefit of using this special dividing character is pretty wonderful: the standard get method does not need to change at all. Since we’re just sending a slightly more detailed identifier, we can rely on the same id field in the standard get request and allow the API service to interpret it as a resource at a given revision.
这确实引出了一个有趣的问题:当资源以特定修订响应时,该资源的 ID 字段中应该包含什么?换句话说,如果我们请求修订版 1234 的资源,ID 是否会与请求中提供的内容完全匹配(例如,resources/abcd@1234),或者我们是否应该退回到仅使用资源标识符(例如,resources/abcd)并将修订版放入revisionId字段中?
This does lead to an interesting question: when the resource responds with a specific revision, what should go in that resource’s ID field? In other words, if we request a resource at revision 1234, would the ID match what was provided in the request exactly (e.g., resources/abcd@1234) or should we fall back to just the resource identifier alone (e.g., resources/abcd) and put the revision into the revisionId field?
答案很简单:返回的资源应该有一个与请求的完全相同的标识符(并且该revisionId字段应该始终填充返回的修订版的标识符)因为重要的是我们可以轻松地断言我们得到的正是我们所要求的要求。
The answer is pretty simple: the resource returned should have an identifier equivalent to exactly what was asked for (and the revisionId field should always be populated with the identifier of the revision returned) because it’s important that we can easily assert we were given exactly what we asked for.
代码清单 28.4 you get what you ask for 的不变量
Listing 28.4 The invariant of you get what you ask for
assert(GetResource({ id: id }).id == id); ❶
assert(GetResource({ id: id }).id == id); ❶
❶ Retrieving something by its ID should always return a result with that same ID.
这意味着当我们在不指定修订 ID 的情况下请求资源时,id返回的资源字段将只有资源 ID(没有“ @”符号和修订 ID),但该revisionId字段仍应填充。如果我们随后请求相同的资源修订(基于revisionId字段),结果将具有相同的数据,但id字段也会包括修订 ID。
This means that when we ask for a resource without specifying the revision ID, the id field of the resource returned would have only the resource ID (with no “@” symbol and no revision ID), but the revisionId field should still be populated. If we then made a request for that same resource revision (based on revisionId field), the result would have the same data, but the id field would include the revision ID as well.
Listing 28.5 Client-side interaction showing different identifiers for retrieved resources
> GetMessage({ id: 'chatRooms/1/messages/2' }); ❶ { id: 'chatRooms/1/messages/2', ❶ // ..., revisionId: 'abcd' } ❷ > GetMessage({ id: 'chatRooms/1/messages/2@abcde' }); ❸ { id: 'chatRooms/1/messages/2@abcd', ❸ // ..., revisionId: 'abcd' } ❷
> GetMessage({ id: 'chatRooms/1/messages/2' }); ❶ { id: 'chatRooms/1/messages/2', ❶ // ..., revisionId: 'abcd' } ❷ > GetMessage({ id: 'chatRooms/1/messages/2@abcde' }); ❸ { id: 'chatRooms/1/messages/2@abcd', ❸ // ..., revisionId: 'abcd' } ❷
❶当我们请求没有修订 ID 的资源时,结果具有相同的 ID(没有修订 ID)。
❶ When we request a resource with no revision ID, the result has an identical ID (without a revision ID).
❷ The revisionId field is always populated with the correct value.
❸当我们请求相同的资源但同时提供修订 ID 时,修订包含在结果的 ID 中。
❸ When we request the same resource but also provide a revision ID, the revision is included in the result’s ID.
现在我们已经了解了如何检索单个资源修订版,让我们看看如何浏览所有修订版,类似于我们如何浏览给定资源的所有资源类型。
Now that we’ve looked at how to retrieve a single resource revision, let’s look at how to browse through all revisions, similarly to how we might browse through all resources of a given type.
幸运的是对于我们来说,基于我们对标准列表方法的探索,我们在列出资源方面有相当多的经验(请参阅第 7.3.4 节)。然而,在这种情况下,我们列出的是与单个资源相关联的修订,而不是不同父资源的子资源。换句话说,虽然我们可以为这个功能借鉴大部分相同的原则,但我们不能简单地复制和粘贴标准列表方法并使其适用于资源修订。
Luckily for us, we have quite a bit of experience with listing resources based on our exploration of the standard list method (see section 7.3.4). In this case, however, we’re listing revisions that are tied to a single resource rather than child resources of a different parent. In other words, while we can draw on most of the same principles for this functionality, we can’t simply copy and paste the standard list method and make it work for resource revisions.
我们必须对标准列表方法进行哪些更改?对于初学者来说,我们不是GET在资源集合上使用 HTTP 方法,而是依赖一些看起来更像自定义方法的东西,映射到GET /resources/*:listRevisions. 此外,由于目标本身不是父级,而是保存修订的实际资源,因此我们将使用名为的字段id而不是名为parent. 除此之外,该方法应该以所有相同的方式工作,甚至支持分页,正如我们在第 21 章中看到的那样。
What do we have to change about the standard list method? For starters, rather than using a HTTP GET method on a collection of resources, we’ll rely on something that looks much more like a custom method, mapping to GET /resources/*:listRevisions. Further, since the target isn’t a parent itself but instead the actual resource on which the revisions are kept, we’ll use a field called id rather than a field called parent. Other than that, the method should work in all the same ways, even supporting pagination, as we saw in chapter 21.
Listing 28.6 API definition for listing resource revisions
abstract class ChatRoomApi { @get("/{id=chatRooms/*/messages/*}:listRevisions") ListMessageRevisions(req: ListMessageRevisionsRequest): ListMessageRevisionsResponse; } interface ListMessageRevisionsRequest { id: string; maxPageSize: number; pageToken: number; ❶ } interface ListMessageRevisionsResponse { results: Message[]; ❷ nextPageToken: string; ❶ }
❶ This custom list revisions method also supports pagination like any other list method.
❷ Note that the response includes the resources themselves with varying revision identifiers.
这种自定义列表修订方法使我们能够浏览给定资源的先前修订历史,依赖于许多与标准列表方法相同的原则。现在我们已经很好地掌握了创建、检索和浏览修订版,让我们尝试做一些与这些修订版更具交互性的事情:回滚到以前的那些。
This custom list revisions method gives us the ability to browse through the history of a given resource’s previous revisions, relying on many of the same principles as the standard list method. Now that we’ve got a good grasp on creating, retrieving, and browsing revisions, let’s try doing something more interactive with these revisions: rolling back to previous ones.
尽管随着时间的推移对资源所做的更改进行历史记录是一项有用的功能,我们可以根据修订历史提供的最有价值的功能之一是能够将资源恢复到它在某个时间点的样子过去的。为此,我们将依赖自定义恢复修订方法,该方法负责使用特定的现有修订作为资源数据源来创建资源的新修订。由于这个新版本是最新的(因此具有最新的revisionCreateTime值),它成为资源的当前表示。在某种程度上,这有点像浏览旧版本文档的文件,复印该文档,然后将新版本放在堆栈顶部。在这种情况下,复印件成为最新修订版,尽管它是较旧的数据。
While having a historical record of the changes made to a resource over time is a useful feature, one of the most valuable bits of functionality we can provide based on revision history is the ability to restore a resource to the way it looked at some point in the past. To do this, we’ll rely on a custom restore revision method, which has the responsibility of creating a new revision of a resource using a specific existing revision as the source of the resource’s data. Since this new revision is the newest (and therefore has the most recent revisionCreateTime value), it becomes the current representation of the resource. In a way, it’s sort of like going through a file for an old version of a document, making a photocopy of that document, and putting the new one at the top of the stack. In this case, the photocopy becomes the latest revision despite it being older data.
Listing 28.7 API definition for restoring resource revisions
abstract class ChatRoomApi { @post("/{id=chatRooms/*/messages/*}:restoreRevision") ❶ RestoreMessageRevision(req: RestoreMessageRevisionRequest): Message; } interface RestoreMessageRevisionRequest { id: string; revisionId: string; ❷ }
❶ To restore a previous revision, we use a custom method attached to the resource.
❷ The restore revision request must also indicate the revision that should be restored.
重要的是要记住,即使我们正在创建一个新的自定义方法,此功能目前也可用于现有的构建块(它不是原子的或几乎不方便)。
It’s important to remember that even though we’re creating a new custom method, this functionality is currently available with the existing building blocks (it’s just not atomic or nearly as convenient).
Listing 28.8 Client-side implementation of restoring a prior revision
function restoreMessageRevision(messageId: string, revisionId: string): Message { const old = GetMessage({ ❶ id: `${messageId}@${revisionId}` }); UpdateMessage({ ❷ resource: Object.assign(old, { id: messageId }) }); return CreateMessageRevision({ id: messageId }); ❸ }
❶ We start by retrieving an older revision of the resource.
❷之后,我们用旧数据更新资源(确保我们以消息本身为目标,而不是旧版本)。
❷ After that, we update the resource with the older data (ensuring that we target the message itself and not the old revision).
❸ If a new revision isn’t created automatically, we should explicitly create one and return the result.
为什么不让用户自己实现这个功能呢?与许多这些自己动手的 API 方法一样,我们不得不担心并发性、原子性和便利性。实现的这个功能需要三个完整的来回通信线路,其中任何一个都可能失败、延迟或被与同一资源交互的其他用户中断。通过提供与自定义方法相同的功能,我们为用户提供了一种原子方式来执行对先前修订的恢复。
Why not just allow users to implement this functionality themselves? As is the concern with many of these do-it-yourself API methods, we have to worry about concurrency, atomicity, and convenience. This function as implemented requires three full back-and-forth lines of communication, any of which might fail, be delayed, or be interrupted by other users interacting with the same resource. By providing the same functionality as a custom method, we give users an atomic way to perform this restoration to previous revisions.
关于此方法要记住的关键是它不会删除或更改资源的历史记录。它不会将旧版本移至行首(例如,通过更改创建时间戳以显示为最新版本)。相反,它创建了一个全新的修订版,从旧的修订版复制而来,并将该副本放在堆的顶部,尽管具有不同的修订版标识符。这确保了当我们浏览历史记录时,我们可以清楚地看到数据随时间变化的进程,而不会混淆移动到不同位置的修订。
The critical thing to remember about this method is that it does not remove or alter the history of the resource. It doesn’t take an old revision and move it to the front of the line (e.g., by changing the creation timestamp to appear as the newest). Instead, it creates an entirely new revision, copied from an old one, and puts that duplicate at the top of the pile, though with a different revision identifier. This ensures that when we’re browsing history, we can clearly see the progression of data changes over time and not get confused with revisions being moved into different positions.
可能有一天我们确实需要改写历史,尽管这看起来很不幸。在下一节中,我们将探讨如何通过删除来更改历史记录修订。
There may come a time when we do need to rewrite history, as unfortunate as that may seem. In the next section, we’ll explore how we can alter the historical record by removing revisions.
很遗憾,我们都会犯错。虽然资源修订可以让我们回顾资源更改的历史,但它们也会让我们的错误永远存在,根本不会被遗忘。这似乎是一个小小的不便,但想象一下,如果我们不小心将一些敏感数据(社会安全号码、信用卡号码、健康信息)存储在支持修订的资源中。当我们去删除有问题的数据时,它实际上并没有消失,因为它可能仍然存在于以前的修订版中。您可以猜到,这可能会成为一个非常大的问题。
Unfortunately, we all make mistakes. And while resource revisions are powerful for letting us look back at the history of changes to a resource, they also make it so that our mistakes live on forever and simply cannot be forgotten. This might seem like a minor inconvenience, but imagine if we were to accidentally store some sensitive data (Social Security numbers, credit card numbers, health information) in a resource that supports revisioning. When we go to remove the offending data, it’s never actually gone since it would presumably still be present in a previous revision. As you can guess, this can become a very big problem.
为了解决这个问题,在许多系统中,支持一种我们可以删除资源修订的方法至关重要。与我们讨论过的其他几个方法一样,此方法依赖于与标准删除方法相同的原则,通过其唯一标识符删除资源,在本例中是资源标识符与修订标识符的组合。
To address this, in many systems it will be critical to support a method by which we can delete revisions of resources. This method, like several others we’ve discussed, relies on the same principles as the standard delete method, removing a resource by its unique identifier, which in this case is the resource identifier combined with the revision identifier.
Listing 28.9 API for deleting a resource revision
abstract class ChatRoomApi { @delete("/{id=chatRooms/*/messages/*}:deleteRevision") ❶ DeleteMessageRevision(req: DeleteMessageRevisionRequest): void; } interface DeleteMessageRevisionRequest { id: string; ❷ }
❶和标准的delete方法一样,我们映射到HTTP DELETE方法。
❶ Just like the standard delete method, we map to the HTTP DELETE method.
❷ All we need to provide is the full resource revision identifier (including the resource identifier).
虽然此方法看起来非常简单明了,但有几个问题值得更详细地探讨。一个好的起点是为什么这个方法应该存在。
While this method looks quite simple and straightforward, there are a couple of questions worth exploring in more detail. A good place to start is why this method should exist at all.
Overloading the standard delete method
没有我们只是在第 28.3.3 节中争辩说,如果唯一的区别是标识符,我们可以重用现有的标准方法来执行类似的工作吗?为什么不重用标准的删除方法并接受修订标识符呢?
Didn’t we just argue in section 28.3.3 that we can reuse the existing standard method to perform similar work if the only difference is the identifier? Why not just reuse the standard delete method and accept revision identifiers also?
答案很简短:很容易把它们混在一起。由于删除数据会造成相当大的损害,因此非常明显地区分删除资源和删除资源的单个修订版之间的区别很重要。如果我们不将这些 API 方法彼此分开,则错误的变量替换可能是删除资源的单个修订版和删除所有修订版(以及资源本身)之间的区别。依赖单独的自定义方法比重载现有的标准删除方法要安全得多方法。
The answer is pretty short: it’s easy to mix these up. Since deleting data is something that can do quite a bit of damage, it’s important to make the distinction between deleting a resource and deleting a single revision of a resource exceedingly obvious. If we don’t separate these API methods from one another, a mistaken variable substitution could be the difference between removing a single revision of a resource and removing all revisions (and the resource itself). It’s far safer to rely on a separate custom method rather than overloading the existing standard delete method.
什么如果用户想要删除资源的当前(最新)修订版,会发生什么?如果我们允许这样做,我们将有效地提供一种在单个 API 方法中删除修订和恢复另一个修订的方法,因为下一个最新修订将成为新的“当前”修订。虽然这当然是一个诱人的提议,但它会扩大自定义删除修订方法的范围,超出其初衷。如果客户端尝试删除最新的修订版,请求应该会失败并出现412 Precondition FailedHTTP 错误或同等学历。幸运的是,这种行为还确保我们不必处理用户试图删除唯一剩余(因此是当前)的情况修订。
What happens if a user wants to delete the current (most recent) revision of a resource? If we were to allow this, we’d effectively be providing a way to delete a revision and restore another revision in a single API method, as the next most recent revision would become the new “current” revision. While this is certainly a tempting proposition, it would expand the scope of the custom delete revision method beyond its original intent. If a client attempts to delete the most recent revision, the request should fail with a 412 Precondition Failed HTTP error or equivalent. Luckily, this behavior also ensures that we don’t have to deal with the scenario where a user is trying to delete the sole remaining (and therefore current) revision.
最后,我们必须考虑如何解决与修订相关的软删除主题(第 25 章)。修订应该能够被软删除吗?如果是这样,修订也应该以同样的方式进行吗?修订通常不应被视为有资格被软删除。事实上,即使资源本身可以被软删除,重要的是我们有能力恢复以前的修订,它可能从软删除状态跳转到非删除状态!因此,当我们删除修订时,它们应该只被硬删除并从中消失这系统。
Finally, we have to consider how we address the topic of soft deletion (chapter 25) with regard to revisions. Should revisions be able to be soft deleted? If so, should the revisions also be the same way? Revisions should generally not be considered eligible to be soft deleted. In fact, even if the resource itself can be soft-deleted, it’s important that we have the ability to restore a previous revision, which may jump from a soft-deleted state to a non-deleted state! As a result, when we delete revisions, they should be hard deleted only and disappear from the system.
因此到目前为止,我们做了一个相当大的假设:资源修订是单个资源及其直接嵌入数据的快照。但是,如果该资源是其他资源的父资源怎么办?比如我们要支持资源的资源修订怎么ChatRoom办?是否应该子Message资源作为ChatRoom修订的一部分保存在一起?或者这些资源修订应该只存储直接有关ChatRoom房间标题的数据吗?
Thus far, we’ve made a pretty big assumption: that resource revisions are snapshots of a single resource and its directly embedded data. But what if that resource is a parent to other resources? For example, what if we want to support resource revisions for ChatRoom resources? Should the child Message resources be kept together as part of the ChatRoom revision? Or should these resource revisions only store the data directly about a ChatRoom such as the title of the room?
这是一个复杂的话题。一方面,如果我们真的想要一个ChatRoom资源的快照,那么恢复到以前的修订版将恢复到创建修订版时存在的消息肯定更有意义。另一方面,对于只想恢复直接嵌入的数据的用户来说,这将意味着更多的工作、更多的存储空间,并最终带来更多的复杂性。
This is a complicated topic. On the one hand, if we truly wanted to have a snapshot of a ChatRoom resource, it would certainly make more sense that restoring to a previous revision would revert to the messages as they existed when the revision was created. On the other hand, this would mean quite a bit more work, more storage space, and ultimately more complexity for users who want to restore only the data embedded directly.
不幸的是,这是没有通用解决方案的场景之一。通常,将修订集中在单个资源而不是其整个子层次结构上是一个完美的解决方案。它也恰好是一个更直接的命题。在其他更独特的场景中,可能需要跟踪子层次结构以及资源本身。
Unfortunately, this is one of those scenarios where there is no one-size-fits-all solution. In general, keeping revisions focused on a single resource and not its entire child hierarchy is a perfectly fine solution. It also happens to be a much more straightforward proposition. In other more unique scenarios, it may be required to keep track of the child hierarchy alongside the resource itself.
简而言之,如果需要,超出资源本身的扩展是完全可以接受的,但要确定这是一个坚定的业务需求,并且其他选项(例如导出方法,如第 23 章所示)不可用。如果可以避免分层感知修订,那么仅凭复杂性就足以避免他们。
In short, if required, it’s perfectly acceptable to expand beyond the resource itself, but be certain that it’s a firm business requirement and that other options (such as an export method, as seen in chapter 23) aren’t available. If hierarchical-aware revisions can be avoided, then complexity alone should be enough reason to avoid them.
在清单 28.10 中,我们可以看到一个示例,其中包含所有特定于修订的 API 方法,用于处理对个人的修订Message资源.
In listing 28.10, we can see an example of all the revision-specific API methods to handle revisions on individual Message resources.
Listing 28.10 Final API definition
abstract class ChatRoomApi { @get("/{id=chatRooms/*/messages/*}") GetMessage(req: GetMessageRequest): Message; @post("/{id=chatRooms/*/messages/*}:createRevision") CreateMessageRevision(req: CreateMessageRevisionRequest): Message; @post("/{id=chatRooms/*/messages/*}:restoreRevision") RestoreMessageRevision(req: RestoreMessageRevisionRequest): Message; @delete("/{id=chatRooms/*/messages/*}:deleteRevision") DeleteMessageRevision(req: DeleteMessageRevisionRequest): void; @get("/{id=chatRooms/*/messages/*}:listRevisions") ListMessageRevisions(req: ListMessageRevisionsRequest): ListMessageRevisionsResponse; } interface Message { id: string; content: string; // ... more fields here ... revisionId: string; revisionCreateTime: Date; } interface GetMessageRequest { id: string; } interface CreateMessageRevisionRequest { id: string; } interface RestoreMessageRevisionRequest { id: string; revisionId: string; } interface DeleteMessageRevisionRequest { id: string; } interface ListMessageRevisionsRequest { id: string; maxPageSize: number; pageToken: string; } interface ListMessageRevisionsResponse { results: Message[]; nextPageToken: string; }
尽管资源修订非常强大,但对于 API 设计者和 API 用户而言,它们也会显着增加复杂性。此外,由于随着时间的推移修改资源并创建修订,会跟踪额外的数据,因此它们可能导致更大的存储空间需求。特别是,即使资源修订是 API 的一项重要功能,层次结构感知修订(其中单个修订包含资源及其所有子资源)更加复杂,并且具有更加极端的存储要求。
While resource revisions are incredibly powerful, they also lead to significantly more complexity, both for the API designer and API users. Further, they can lead to much larger storage space requirements due to the extra data that’s tracked as resources are modified over time and revisions are created. In particular, even if resource revisioning is a critical piece of functionality for an API, hierarchy-aware revisions (where a single revision encompasses both a resource and all its child resources) are even more complex and come with even more extreme storage requirements.
因此,如果可以在 API 中避免资源修订,那当然应该这样做。也就是说,有时修订最适合这项工作(例如,如果 API 正在跟踪法律文件),我们需要永远存储资源的历史记录。在这些情况下,资源修订提供了一种安全且直接的方式来支持 API 的此功能用户。
As a result, if resource revisioning can be avoided in an API, it certainly should be. That said, sometimes revisions are the best fit for the job (e.g., if an API is tracking legal documents) and we need to store the history of the resource forever. In cases like these, resource revisioning provides a safe and straightforward way to support this functionality for API users.
What types of scenarios are a better fit for creating revisions implicitly? What about explicitly?
Why should revisions use random identifiers rather than incrementing numbers or timestamps?
Why does restoration create a new revision? Why not reference a previous one?
What should happen if you attempt to restore a resource to a previous revision but the resource is already equivalent to that previous revision?
Why do we use custom method notation for listing and deleting resources rather than the standard methods?
Resource revisions act as snapshots of resources taken as they change over time.
Revisions should use random identifiers like any other resource even though it might seem to make sense to rely on something like an incrementing number or timestamp as an identifier.
Revisions can be created both implicitly (e.g., whenever a resource is modified) or explicitly (e.g., on request by a user).
检索特定版本的资源可以通过使用“@”字符来指示版本(例如,GET /chatRooms/1234@5678)来完成。但是,列出和删除修订都应该使用附加到相关资源的自定义方法来完成。
Retrieving resources at specific revisions can be done by using an “@” character to indicate the revision (e.g., GET /chatRooms/1234@5678). However, both listing and deleting revisions should be done using a custom method attached to the resource in question.
Resources may be restored to a previous revision’s state using a custom method that creates a new revision equal to the previous one and marks it as the active revision.
当 Web API 发生错误时,有些错误是由于客户端错误造成的,而另一些则是由于客户端无法控制的问题造成的。对于第二组错误的最佳解决方案通常是稍后重试相同的请求,以期获得不同的结果。在此模式中,我们将探索一种机制,通过该机制,客户端可以明确指示重试先前因 API 服务器错误而失败的请求的方式和时间。
When errors occur in web APIs, some of them are due to client mistakes, while others are due to issues outside the client’s control. Often the best solution to this second group of errors is to retry the same request at a later time in hopes of a different result. In this pattern, we’ll explore a mechanism by which clients can have clear direction in both how and when they retry requests that have previously failed due to errors on the API server.
某些请求会失败是 Web API 不可避免的事实——希望不是大多数,但仍然有一些。这些失败的请求中有许多是由于客户端错误,例如请求消息无效,而其他请求可能是由于在 API 服务器上强制执行的先决条件和约束失败。所有这些类型的错误都有一个重要的属性:问题是请求本身违反了某些约束或违反了 API 的业务逻辑。换句话说,如果我们再次重放相同的无效请求,我们应该得到相同的错误响应。毕竟,业务逻辑通常不会时时刻刻发生变化。
It’s an inevitable fact of web APIs that some requests will fail—hopefully not the majority, but some nonetheless. Many of these failed requests will be due to client-side errors such as invalid request messages, while others might be due to failed preconditions and constraints being enforced on the API server. All of these types of errors share an important attribute: the problem is with the request itself violating some constraints or going against the API’s business logic. Put differently, if we replayed the same invalid request again, we should get the same error response. After all, the business logic typically doesn’t change from one moment to another.
整个其他类别的错误是完全不同的。有时,请求会导致完全瞬态的错误响应。这种类型的错误与请求本身完全无关,而是 API 内部某些问题的结果。也许 API 服务器过载并且无法处理传入的请求,或者系统或必要的子组件可能正在经历计划的停机时间并且在处理请求时不可用。不管是什么问题,都有一个明确而明显的方法来解决这个问题:重试请求。
An entire other category of errors is quite different. Sometimes a request leads to an error response that is completely transient. This type of error has absolutely nothing to do with the request itself and is instead the result of some problem internal to the API. Perhaps the API server is overloaded and wasn’t able to handle the incoming request, or maybe the system or a necessary subcomponent was undergoing scheduled downtime and was not available when processing the request. Regardless of the problem, there’s a clear and obvious way to address the issue: retry the request.
关键是错误响应并不是真正由特定请求引起的。相反,响应是由于充其量与请求无关的事物引起的,并且更有可能与请求无关。这意味着如果我们在将来的某个时间简单地再次尝试相同的请求,那么重试很有可能会成功(或者至少会导致不同的错误)。
The point is that the error response wasn’t really caused by the specific request made. On the contrary, the response was due to things that were at best tangentially related to the request and far more likely to have nothing to do with the request. This means that if we were to simply try the same request again at some point in the future, there’s a good chance that the retrial will succeed (or, at the very least, result in a different error).
那么最大的问题就变成了我们如何准确地确定应该重试和不应该重试的请求之间的区别。通常,对 HTTP 错误进行编号以传达出了什么问题,某些错误比其他错误更有可能重试。例如,400 级错误(例如400 Bad Request,403 Forbidden,404 Not Found等)可能是特定请求的问题,而 500 级错误(例如,500 Internal Server Error要么503 Service Unavailable) 更有可能表示内部服务存在问题。在这两个类别中,500 级错误更可能是可重试的,但这更像是一个指南而不是实际规则,因此这当然需要更多讨论。此外,虽然这些错误代码可能暗示请求是否可以重试,但它们并没有真正说明客户端何时应该重试失败的请求,而是让客户端猜测在再次尝试相同请求之前需要等待多长时间。
The big question then becomes how exactly we can determine the difference between a request that should be retried and one that shouldn’t. In general, HTTP errors are numbered to communicate what went wrong, with certain errors more likely to be retriable than others. For example, 400-level errors (e.g., 400 Bad Request, 403 Forbidden, 404 Not Found, etc.) are likely problems with a particular request, whereas 500-level errors (e.g., 500 Internal Server Error or 503 Service Unavailable) are more likely to represent problems with internal services. Of these two categories, the 500-level errors are more likely to be retriable, but that’s more of a guideline than an actual rule, so this certainly needs some more discussion. Further, while these error codes might hint at whether a request can be retried, they don’t really say much about when a client should retry a failed request, leaving the client guessing at how long to wait before trying the same request again.
在此模式中,我们将探索 API 服务如何定义重试策略来确定哪些请求符合重试条件以及计时算法以确定在重试之前等待多长时间。我们还将介绍 API 服务如何在服务知道一些附加信息的情况下通知客户端确切的重试时间。
In this pattern, we’ll explore how API services can define a retry policy for both determining which requests are eligible to be retried as well as the timing algorithm to determine how long to wait before retrying. We’ll also cover how the API service might inform a client of exactly when to retry in the case that the service is aware of some additional information.
此模式的目标很简单:响应尽可能多的请求,同时尽可能少地重试。要做到这一点,我们需要解决两个问题。首先,我们必须为客户端提供可遵循的算法,以最大限度地减少跨系统重试的请求数。其次,如果 API 服务知道客户端不知道的事情,并且此信息将导致可以成功重试请求的特定时间,则该服务应该有一种机制来为客户端提供何时重试请求的明确指示要求。让我们首先看一下用于确定重试请求之间的时间延迟的算法。
The goal of this pattern is simple: respond to as many requests as possible while retrying as few as possible. To accomplish this, we need to address two issues. First, we must provide the clients with an algorithm to follow in order to minimize the number of requests being retried across the system. Second, if the API service knows something the client does not, and this information would lead to a specific time at which a request could be retried successfully, the service should have a mechanism to provide the client with an explicit instruction of when to retry a request. Let’s start by looking at the algorithm for determining the timing delay between retrying requests.
什么时候我们遇到了一个可重试的失败,我们几乎肯定会尝试再次发送相同的请求,希望服务已经可用。但是我们应该马上这样做吗?还是我们应该稍等一下?如果我们应该等,那么我们应该等多久?
When we run into a failure that’s retriable, we should almost certainly try to send the same request again in the hopes that the service has become available. But should we do so right away? Or should we wait a bit? If we should wait, then how long should we wait?
事实证明,这是一个非常古老的问题,可以追溯到互联网诞生之初,当时我们需要一种算法来知道何时应该在可能拥塞的网络上重试发送 TCP 数据包。这特别棘手,因为我们不知道发送请求后会发生什么,这让我们只能猜测何时更有可能在另一端成功处理请求。基于这些约束,针对此问题最有效、经过实战检验的算法是指数退避算法。
It turns out that this is a very old problem, going back to the beginning of the internet when we needed an algorithm to know when we should retry sending TCP packets over a potentially congested network. It’s particularly tricky because we have no idea what’s happening once the request is sent, leaving us to simply make guesses about when the request is more likely to be handled successfully on the other side. Based on these constraints, the most effective, battle-tested algorithm for this problem is exponential back-off.
指数退避的想法非常简单。对于第一次重试尝试,我们可能会等待一秒钟,然后再次发出请求。之后,对于每个后续的失败响应,我们都会将之前等待的时间加倍。如果请求第二次失败,我们等待两秒钟,然后重试。如果该请求失败,我们将等待四秒钟并再次尝试。这将继续加倍(8、16、32 秒)直到时间结束或直到我们决定放弃。我们将向该算法引入一些额外的部分,但核心概念将保持不变。
The idea of exponential back-off is pretty simple. For a first retry attempt, we might wait one second before making the request again. After that, for every subsequent failure response we take the time that we waited previously and double it. If a request fails a second time, we wait two seconds and then try again. If that request fails, we wait four seconds and try once more. This would continue doubling (8, 16, 32 seconds) until the end of time or until we decide to give up. We’ll introduce some extra pieces to this algorithm, but the core concept will remain unchanged.
正如我们所指出的,当另一端的系统是一个完整的黑盒子时,该算法非常有用。换句话说,如果我们对系统一无所知,指数退避就可以很好地工作。但是我们的 Web API 是这样吗?事实上,情况并非如此,正如我们将在下一节中看到的那样,对于一个独特的子集,实际上有一个更好的解决方案场景。
As we noted, this algorithm is great when the system on the other side is a complete black box. In other words, if we know nothing else about the system, exponential back-off works quite well. But is that the case with our web API? In truth, this is not the case and, as we’ll see in the next section, there is actually a better solution for a unique subset of scenarios.
尽管指数回退仍然是一个合理的算法,事实证明在某些情况下有更好的选择。例如,考虑请求失败的场景,因为 API 服务表示该请求每分钟只能执行一次。在这种情况下,服务器实际上确切地知道在重试请求之前要等待多长时间(在这种情况下,大约 60 秒)。
While exponential back-off is still a reasonable algorithm, it turns out that there are certain cases in which there is a better option available. For example, consider the scenario where a request fails because the API service says that this request can only be executed once per minute. In this case, the server actually knows exactly how long to wait before retrying the request (in this case, about 60 seconds).
一般来说,只要服务器可以提供一些关于何时重试请求的权威指导,就应该将此信息传回给客户端。毕竟,即使是部分知情的猜测也比盲目猜测好。虽然这些类型的场景并不常见,但它们肯定不会少到可以完全忽视的程度。
In general, whenever the server can provide some authoritative guidance about when a request should be retried, this information should be communicated back to the client. After all, even a partly informed guess is better than a blind guess. And while these types of scenarios are not common, they are certainly not rare enough to disregard entirely.
我们将依赖专门设计用于解决这种情况的参数。此参数(如果存在)指示客户端忽略他们通常使用的指数退避算法,而是仅根据提供的值重试请求以确定何时重试。通过这样做,我们可以推翻盲目猜测,在大多数情况下进行一些更明智的猜测,并在罕见的情况。
We’ll rely on a parameter designed specifically to address this scenario. This parameter, when present, instructs the client to disregard the exponential back-off algorithm they would typically use and instead retry the request based exclusively on the value provided to determine when to retry. By doing so, we can override the blind guessing for some slightly more informed guessing in most cases and absolute certainty in the rare case.
作为我们在上一节中了解到,对于大多数情况,此模式将依赖于指数退避,而对于其他情况,服务器可以指示客户端在重试请求之前确切等待多长时间的特殊字段。然而,在我们深入讨论这些主题的细节之前,房间里有一些大象需要讨论。我们如何确定是否应该重试给定的请求?
As we learned in the previous section, this pattern will rely on exponential back-off for most cases and a special field for other cases where the server can instruct the client exactly how long to wait before retrying requests. However, before we get into the details of these topics, there’s a bit of an elephant in the room to discuss. How do we determine whether a given request should be retried at all?
它可能会假设所有错误都可以重试,但不幸的是,这是一个危险的假设。最大的担忧是这可能会导致一些意想不到的后果,例如重复结果(例如,两次创建相同的资源)。我们如何知道哪些类型的请求可以重试,哪些不能?
It might be tempting to assume that all errors are eligible to be retried, but unfortunately that’s a dangerous assumption. The biggest concern is that this might lead to some unintended consequences such as duplicating results (e.g., creating the same resource twice). How do we know what types of requests can be retried and which cannot?
一般来说,报错响应分为三类:肯定不能重试的,可以重试但需要仔细观察的,以及重试应该没有问题的。另外,能否重试还取决于API方法本身,尤其是是否幂等和安全。在本节中,我们将查看这些类别并总结属于每个类别的 HTTP 错误代码。
In general, there are three categories of error responses: those that definitely cannot be retried, those that might be able to be retried but should be looked at carefully, and those that can probably be retried without any issues. In addition, the ability to retry also depends on the API method itself, particularly whether it is idempotent and safe. In this section, we’ll look at these categories and summarize the HTTP error codes that fall into each one.
很遗憾,被认为可能可重试的错误代码列表相对较短,总结在表 29 中。1.
Unfortunately, the list of error codes that is considered probably retriable is relatively short, summarized in table 29.1.
Table 29.1 Sample of response codes that are generally retriable
在代码408、421、425、429和的情况下503,服务器甚至从未真正开始处理这些请求。我们可以得出的结论是,重试收到这些错误的请求几乎肯定是可以接受的回应。
In the case of codes 408, 421, 425, 429, and 503, the server never actually even began addressing these requests. The conclusion we can draw is that it’s almost certainly acceptable to retry requests that receive these error responses.
在一般而言,如果服务器接收并处理请求但确定该请求在某些基本方面无效,则它通常不可重试。例如,如果我们尝试检索资源并遇到403 Forbidden错误,重试请求不会导致不同的结果,除非 API 中发生其他更改(例如调整权限)。这些类型的错误(其中一些显示在表 29.2 中)很容易发现,因为它们是关于请求本身的错误或服务器错误,表明违反了内在规则。
In general, if a server receives and processes a request but determines that the request was invalid in some fundamental way, it’s usually not retriable. For example, if we try to retrieve a resource and get a 403 Forbidden error, retrying the request won’t lead to a different result unless something else changes in the API (such as permissions being adjusted). These types of errors (a few of them are shown in table 29.2) are pretty easy to spot as they are errors about the requests themselves or server errors stating violations of intrinsic rules.
Table 29.2 Sample of response codes that are definitely not retriable
|
The request was fine, but the server is refusing to handle it. |
||
总的来说,所有这些响应代码都表明这种情况是永久性的,因此将来重试请求不会导致不同的响应。(显然,这假设没有其他变化系统。)
In general, all of these response codes indicate something permanent about the situation such that retrying the request in the future will not lead to a different response. (Obviously this assumes that nothing else changes in the system.)
这最棘手的类别是一组错误代码,其中重试请求可能是可以接受的,但这将取决于有关请求和 API 方法的更多细节。例如,一个504 Gateway Timeout错误表示 API 请求已传递到下游服务器并且该服务器从未回复。因此,我们不确定下游服务器是否确实收到并开始处理请求。基于这种不确定性,我们只能在确定重复尝试不会导致任何问题时重试请求。表 29.3 显示了属于这一类别的请求示例。
The trickiest category is the set of error codes where retrying the request may be acceptable, but it will depend on some more details about the request and the API method. For example, a 504 Gateway Timeout error indicates that the API request was passed off to a downstream server and that server never replied. As a result, we don’t know for sure whether the downstream server actually received and began working on the request. Based on this uncertainty, we can only retry the request if we’re certain a repeated attempt wouldn’t cause any problems. A sample of requests that fall in this category is shown in table 29.3.
Table 29.3 Sample of response codes that might be retriable
|
The request was passed to a downstream server that sent an invalid response. |
||
|
The request was passed to a downstream server that never replied. |
与往常一样,确保我们确实可以重试请求的最佳机制是依赖请求标识符作为一种允许服务器删除重复请求并避免不需要的行为的方法,如第 26 章所述。
As always, the best mechanism for ensuring that we can indeed retry requests is to rely on the request identifiers as a way to allow the server to deduplicate requests and avoid unwanted behavior, as discussed in chapter 26.
现在我们已经了解了各种请求以及它们是否可重试(以及在什么情况下),让我们切换主题并开始研究我们认为可以接受重试请求的情况。特别是,让我们看看我们将决定在重试之前等待多长时间的方式一种要求。
Now that we have an idea of the various requests and whether they’re retriable (and in what circumstances), let’s switch topics and start looking at the cases where we’ve decided it’s acceptable to retry a request. In particular, let’s look at the way we’ll decide on how long to wait before retrying a request.
作为我们之前了解到,指数退避是一种算法,请求之间的延迟呈指数增长,每次请求返回错误结果时都会加倍。也许理解这一点的最好方法是查看一些代码。
As we learned earlier, exponential back-off is an algorithm by which the delay between requests grows exponentially, doubling each time a request returns an error result. Perhaps the best way to understand this would be by looking at some code.
Listing 29.1 Example demonstrating retrial with exponential back-off
async function getChatRoomWithRetries(id: string): Promise<ChatRoom> { return new Promise<ChatRoom>(async (resolve, reject) => { let delayMs = 1000; ❶ while (true) { try { return resolve(GetChatRoom({ id })); ❷ } catch (e) { await new Promise((resolve) => { ❸ return setTimeout(resolve, delayMs); }); delayMs *= 2; ❹ } } }); }
❶ We define the initial delay as 1 second (1,000 milliseconds)
❷ First, attempt to retrieve the resource and resolve the promise.
❸ If the request fails, wait for a fixed amount of time, defined by the delay.
❹ Finally, double the amount of time we’ll need to wait next time.
虽然此实现是一个很好的起点,但它还可以进行一些改进。让我们从查看一些最大值开始。
While this implementation is a good starting point, it could use a few improvements. Let’s start by looking at some maximums.
作为我们之前注意到,指数退避通常会继续重试请求,直到时间结束或请求成功。尽管我们可能会钦佩该算法的持久性,但这通常不是一个好的策略,因为这意味着完成请求可能需要多长时间是没有限制的。
As we noted earlier, exponential back-off generally will continue retrying a request until the end of time or the request succeeds. And even though we might admire the algorithm’s persistence, this is generally not a good strategy, as it means that there are no bounds on how long it might take a request to finish.
为了解决这个问题,我们可以添加一些限制。首先,我们可以为我们想要尝试的重试次数添加一个限制。然后我们可以为重试请求之间等待的最长时间添加一个限制。
To address this, we can add in some limits. First, we can add a limit to the number of retries we want to attempt. Then we can add a limit to the maximum amount of time to wait between requests being retried.
Listing 29.2 Adding support for maximum delays and retry counts
async function getChatRoomWithRetries( id: string, maxDelayMs = 32000, maxRetries = 10): Promise<ChatRoom> { return new Promise<ChatRoom>(async (resolve, reject) => { let retryCount = 0; ❶ let delayMs = 1000; while (true) { try { return resolve(GetChatRoom({ id })); } catch (e) { if (retryCount++ > maxRetries) return reject(e); ❷ await new Promise((resolve) => { return setTimeout(resolve, delayMs); }); delayMs *= 2; if (delayMs > maxDelayMs) delayMs = maxDelayMs; ❸ } } }); }
❶我们通过增加值并在超过允许的最大值时拒绝来跟踪重试(循环迭代)的次数。
❶ We track the number of retries (loop iterations) by incrementing the value and rejecting if we go past the maximum allowed.
❷我们通过增加值并在超过允许的最大值时拒绝来跟踪重试(循环迭代)的次数。
❷ We track the number of retries (loop iterations) by incrementing the value and rejecting if we go past the maximum allowed.
❸ If the new delay time is longer than the maximum, we change it to the maximum.
有了这些新的限制,我们就有办法确保该功能最终会失败,有效地说,“我们试了又试,但无法获得成功的响应。” 既然已经涵盖了这一点,还有另一个主题需要解决:stampeding牛群。
With these new limits, we have a way to ensure the function will eventually fail, effectively saying, “We tried and tried but couldn’t get a successful response.” Now that this is covered, there’s one other topic to address: stampeding herds.
一种stamping herd并不是指在平原上奔跑的动物。在技术上,蜂拥而至是指一群远程客户端同时发出相同请求,导致接收端系统超载的情况。简而言之,系统可能能够单独处理每个请求,但肯定不能同时处理所有请求,因为大量并发会使系统不堪重负。但这与指数退避有什么关系呢?
A stampeding herd does not refer to literal animals running across the plains. In technology, a stampeding herd refers to a case where a bunch of remote clients all make the same request at the same time, overloading the system on the receiving end. In short, the system might be able to handle each of the requests individually, but certainly not all at the same time as the massive concurrency overwhelms the system. But what does this have to do with exponential back-off?
为了理解这种联系,让我们考虑一下我们可能有 100 个不同的客户端同时发送请求的情况——可以说是蜂拥而至的群体。当这些客户端中的每一个都看到他们的请求返回错误时,他们将立即开始重试,希望后续请求能够成功。然而,问题在于它们都将根据相同的指数退避算法执行此操作。结果是这 100 个请求中的每一个都将继续同时到达服务器,只是每次请求之间的间隔更大。换句话说,因为客户端碰巧在意外同步的情况下登陆,所以请求不断相互阻碍,以至于没有一个成功。从某种意义上说,
To understand the connection, let’s consider the case where we might have 100 different clients all sending a request at the same time—a stampeding herd, so to speak. When each of these clients sees that their request has returned an error, they’ll immediately begin retrying in the hope that a subsequent request will be successful. The problem, however, is that they’ll all do so according to the same exponential back-off algorithm. The result is that each of these 100 requests will continue to arrive at the server at the same time, just with larger gaps in between each stampede. In other words, because the clients happened to land in a situation where they’re accidentally synchronized, the requests keep getting in the way of each other to the point where none of them succeed. In a sense, the deterministic nature of the algorithm leads to its downfall.
我们能做些什么呢?一个简单的解决方案是引入一个客户端与另一个客户端不同的东西,而不是完全遵循指数退避算法。为此,我们将依赖随机抖动的概念在请求之间的延迟时间。换句话说,除了指数退避算法规定的时间延迟之外,我们所要做的就是添加一些随机的等待时间。
What can we do about this? A simple solution is to introduce something that differs from one client to another rather than perfectly following the exponential back-off algorithm. To do this, we’ll rely on the concept of random jitter in the delay timing between requests. In other words, all we have to do is add some random amount of time to wait in addition to the time delays dictated by the exponential back-off algorithm.
Listing 29.3 Adding jitter to avoid stampeding herds of requests
async function getChatRoomWithRetries( id: string, maxDelayMs = 32000, maxRetries = 10): Promise<ChatRoom> { return new Promise<ChatRoom>(async (resolve, reject) => { let retryCount = 0; let delayMs = 1000; while (true) { try { return resolve(GetChatRoom({ id })); } catch (e) { if (retryCount++ > maxRetries) return reject(e); await new Promise((resolve) => { return setTimeout(resolve, delayMs + (Math.random() * 1000)); ❶ }); delayMs *= 2; if (delayMs > maxDelayMs) delayMs = maxDelayMs; } } }); }
❶ Add up to 1,000 milliseconds of jitter to avoid stampeding herds.
请注意,此处引入的随机抖动不是累加的。换句话说,它不包括在退避算法不断加倍的延迟中。
Note that the random jitter introduced here is not additive. In other words, it’s not included in the delay that is continually doubled by the back-off algorithm.
现在我们已经深入了解了指数退避算法的工作原理,让我们换个话题,考虑 API 服务器可能知道客户端需要多长时间的场景。应该等待。
Now that we have an idea of how the exponential back-off algorithm works in depth, let’s switch gears and consider the scenario where an API server might know how long a client should wait.
指数型回退(具有第 29.3.1 节中的增强功能)是确定何时重试给定请求的一个很好的选择,但我们对这种算法的偏好通常基于这样的假设,即我们基本上没有可以帮助我们做的额外信息任何更聪明的东西。然而,在某些情况下,我们知道情况并非如此。最常见的例子是由于速率限制导致的错误,其中客户端发送请求过于频繁,而我们可以控制何时允许下一个请求。换句话说,由于 API 服务器确切地知道这个服务强加的限制何时会被重置,它也处于一个独特的位置,它可以准确地说出何时应该重试请求,这样它就不会再违反速率限制规则.
Exponential back-off (with the enhancements in section 29.3.1) is a great option for determining when to retry a given request, but our preference for this algorithm is generally predicated on the assumption that we have basically no additional information that could help us do anything smarter. In some cases, however, we know this isn’t the case. The most common example of this is an error due to rate limiting, where the client is sending requests too frequently and we are in control of when the next request is allowed. In other words, since the API server knows exactly when this service-imposed limit will be reset, it also is in a unique position where it can say exactly when a request should be retried such that it will no longer violate the rate-limit rules.
指示客户跳过指数回退例程而只是按照指示执行操作的最佳方法是什么?事实证明,在 HTTP 的语义和内容规范中有一个针对此主题的互联网工程任务组 (IETF) 标准:RFC-7231 ( https://tools.ietf.org/html/rfc7231 )。此 RFC 指定了一个“Retry-After”HTTP 标头(第 7.1.3 节),它指示客户端“在发出后续请求之前应该等待多长时间”。简而言之,此 HTTP 标头是 API 服务器发送有关何时应重试请求的更具体说明的理想场所。
What’s the best way to instruct a client that they should skip the exponential back-off routine and instead just do as instructed? It turns out that there is an Internet Engineering Task Force (IETF) standard for just this topic, in the semantics and content specification of HTTP: RFC-7231 (https://tools.ietf.org/html/rfc7231). This RFC specifies a “Retry-After” HTTP header (section 7.1.3), which indicates how long a client “ought to wait before making a follow-up request.” In short, this HTTP header is the perfect place for an API server to send more specific instructions about when a request should be retried.
这就引出了下一个问题:我们如何在这个 HTTP 标头中格式化我们的延迟指令?
This leads to the next question: how do we format our delay instructions in this HTTP header?
这规范明确规定了 Retry-After 标头可以包含日期值(例如,Fri, 31 Dec 1999 23:59:59 GMT)或重试之前要延迟的秒数(例如,120)。尽管规范(和大多数现代 HTTP 服务器)将支持这两种格式,但依赖持续时间而不是时间戳几乎总是更好的选择。
The specification explicitly states the Retry-After header can contain either a date value (e.g., Fri, 31 Dec 1999 23:59:59 GMT) or a duration of seconds to delay before making the retry (e.g., 120). Despite the fact that the specification (and most modern HTTP servers) will support both formats, it’s almost always a better choice to rely on the duration rather than a timestamp.
这样做的原因是时间是一件棘手的事情。如果我们依赖持续时间,则它由服务器生成,然后通过网络发送回客户端。这意味着客户端最终等待的总持续时间略长于指定的持续时间。例如,如果服务器120在 Retry-After 标头中放置一个值,并且响应从服务器传回客户端需要 100 毫秒,那么客户端实际上总共会延迟 120.1 秒。
The reason for this is time is a tricky thing. If we rely on a duration, it is generated by the server and then sent over the network back to the client. This means that the total duration that a client will end up waiting is slightly longer than the duration specified. For example, if a server puts a value of 120 in the Retry-After header, and it takes 100 milliseconds for the response to travel from the server back to the client, then the client will actually end up delaying for 120.1 seconds in total.
将此与涉及时钟同步的更可怕的场景进行比较. 如果服务器通知客户端重试请求的具体时间,则假定服务器和客户端都同意当前时间。这不仅会受到持续时间格式 ( 120) 中存在的相同类型的传输延迟的影响,如果客户端的时钟恰好与服务器的时钟不同步,它可能会导致更大的时间差异。例如,如果服务器认为现在是中午,而客户端认为是下午 1:00,当服务器说“下午 1:00 之前不要重试”时,客户端实际上会立即重试,而不是延迟 60 分钟服务器。
Compare this to the much scarier scenario involving clock synchronization. If the server informs the client about a specific time after which it should retry the request, this assumes that the server and the client both agree on the current time . Not only does that suffer from the same types of transit delays that are present in the duration format (120), it could lead to far larger differences in time if the client’s clock happens to be out of sync with the server’s clock. For example, if the server thinks it’s noon and the client thinks it’s 1:00 p.m., when the server says “Don’t retry until 1:00 p.m.,” the client would actually retry immediately rather than the 60-minute delay intended by the server.
虽然所有这一切看起来有点牵强,但多年来,时钟一直是许多系统的垮台。由于闰秒、时区复杂性和时钟同步问题,假设两个系统具有彼此一致的时钟几乎不是一个好主意(更糟糕的是依赖这一事实来获得正确的行为)。如果可能的话,最安全的做法是始终依赖持续时间而不是特定的时间值。
While all of this might seem a bit far-fetched, clocks have been the downfall of many systems over the years. With leap seconds, time zone complexity, and clock synchronization problems, it’s rarely a good idea to assume that two systems have clocks that agree with one another (and even worse to depend on this fact for proper behavior). The safest bet is to always rely on durations rather than a specific time value, if possible.
现在我们已经阐明了 Retry-After 标头中应该有什么值,我们可以看到我们能够多么容易地实现支持为了这个。
Now that we’ve clarified what value should go in the Retry-After header, we can see just how easily we’re able to implement support for this.
Listing 29.4 Adding support for dictated retry delay durations
async function getChatRoomWithRetries( id: string, maxDelayMs = 32000, maxRetries = 10): Promise<ChatRoom> { return new Promise<ChatRoom>(async (resolve, reject) => { let retryCount = 0; let delayMs = 1000; while (true) { try { return resolve(GetChatRoom({ id })); } catch (e) { if (retryCount++ > maxRetries) return reject(e); await new Promise((resolve) => { let actualDelayMs; if ('Retry-After' in e.response.headers) { ❶ actualDelayMs = Number( e.response.headers['Retry-After']) * 1000; } else { actualDelayMs = delayMs + (Math.random() * 1000); ❷ } return setTimeout(resolve, actualDelayMs); }); delayMs *= 2; if (delayMs > maxDelayMs) delayMs = maxDelayMs; } } }); }
❶如果提供了 Retry-After 值,我们将其用作延迟持续时间。
❶ If the Retry-After value is provided, we use that as a delay duration.
❷ Otherwise, we fall back to the standard exponential back-off algorithm.
推杆一切都在一起,清单 29.5 显示了一个示例,演示客户端应该如何理想地检索资源,包括使用指数退避的重试和速率限制意识秒。
Putting everything together, listing 29.5 shows an example demonstrating how clients should ideally retrieve a resource, including retries with exponential back-off and rate-limit awareness.
Listing 29.5 Final API definition
async function getChatRoomWithRetries( id: string, maxDelayMs = 32000, maxRetries = 10): Promise<ChatRoom> { return new Promise<ChatRoom>(async (resolve, reject) => { let retryCount = 0; let delayMs = 1000; while (true) { try { return resolve(GetChatRoom({ id })); } catch (e) { if (retryCount++ > maxRetries) return reject(e); await new Promise((resolve) => { let actualDelayMs; if ('Retry-After' in e.response.headers) { actualDelayMs = Number( e.response.headers['Retry-After']) * 1000; } else { actualDelayMs = delayMs + (Math.random() * 1000); } return setTimeout(resolve, actualDelayMs); }); delayMs *= 2; if (delayMs > maxDelayMs) delayMs = maxDelayMs; } } }); }
这这种设计模式的不幸之处在于它依赖于客户按照指示进行操作。这可能遵循使用指数退避或在准确的时间后重试的说明,但要点仍然是决定最终留给客户,我们无法控制客户。
The unfortunate thing about this design pattern is that it relies on clients doing as instructed. This might be following instructions to use exponential back-off or to retry after an exact amount of time, but the point remains that the decision is ultimately left to the client, over whom we have no control.
这种模式的好处是我们真的不会因为依赖它而放弃任何东西。指数退避是一种通用标准,已使用多年,在世界范围内取得了巨大成功。当服务器有一些特殊信息时,我们只会规定特定的重试时间,因此如果遵循说明,可以减少重试请求。换句话说,这种模式通常没有缺点。
The upside about this pattern is that there really isn’t anything we give up by relying on it. Exponential back-off is a common standard that has been used for many years, with great success across the world. And we only ever dictate a specific retry time when the server has some special information and can therefore lead to fewer retry requests if the instructions are followed. In other words, this pattern is generally without drawbacks.
Why isn’t there a simple rule for deciding which failed requests can safely be retried?
What is the underlying reason for relying on exponential back-off? What is the purpose for the random jitter between retries?
以某种方式暂时或与时间相关的错误(例如 HTTP 429 Too Many Requests)可能是可重试的,而那些与某些永久状态相关的错误(例如 HTTP 403 Forbidden)重试大多是不安全的。
Errors that are in some way transient or time related (e.g., HTTP 429 Too Many Requests) are likely to be retriable, whereas those that are related to some permanent state (e.g., HTTP 403 Forbidden) are mostly unsafe to retry.
每当代码自动重试请求时,它应该依赖某种形式的指数退避,限制重试次数和请求之间的延迟。理想情况下,它还应该引入一些抖动以避免所有请求都根据相同规则重试并因此总是同时到达的蜂群问题。
Whenever code automatically retries requests, it should rely on some form of exponential back-off with limits on the number of retries and the delay between requests. Ideally it should also introduce some jitter to avoid the stampeding herd problem where all requests are retried according to the same rules and therefore always arrive at the same time.
If the API service knows something about when a request is likely to be successful if retried, it should indicate this using a Retry-After HTTP header.
在此模式中,我们将探索如何以及为何使用公私密钥交换和数字签名 ( https://en.wikipedia.org/wiki/Digital_signature ) 来验证所有传入的 API 请求。这可确保所有入站请求都保证完整性和来源真实性,并且它们以后不会被发件人拒绝。虽然替代方案(例如,共享机密和 HMAC;https://en.wikipedia.org/wiki/HMAC)在大多数情况下都是可以接受的,但在需要不可否认性的情况下引入第三方时,这些替代方案就失败了。
In this pattern, we’ll explore how and why to use public-private key exchange and digital signatures (https://en.wikipedia.org/wiki/Digital_signature) to authenticate all incoming API requests. This ensures that all inbound requests have guaranteed integrity and origin authenticity and that they cannot be later repudiated by the sender. While alternatives (e.g., shared secrets and HMAC; https://en.wikipedia .org/wiki/HMAC) are acceptable in the majority of cases, these fail when it comes to introducing third parties where nonrepudiation is required.
到目前为止,我们只是假设所有 API 请求都保证是真实的,安全问题留待以后处理。正如您可能猜到的那样,现在是我们需要探索一个基本问题的时候了:给定一个入站 API 请求,我们如何确定它来自实际的授权用户?
So far, we’ve simply assumed that all API requests are guaranteed to be authentic, leaving security to be dealt with later on. As you might guess, now is the time where we need to explore a fundamental question: given an inbound API request, how can we determine that it came from an actual authorized user?
最终是否接受给定请求的决定归结为是或否的答案,但我们需要考虑几个不同的要求来做出这个二元决定。首先,我们需要知道请求是否来自它声称的同一用户。换句话说,如果请求声明它是由用户 1234 发送的,我们需要能够确定它确实来自用户 1234(对于我们对用户 1234 的定义)。接下来,我们需要确保请求内容没有被破坏或篡改。最后,一个不感兴趣的第三方能够毫无疑问地验证请求确实来自特定用户可能会变得很重要。换句话说,如果我们发现一个声称是从用户 1234 发送的请求(这是可验证的),用户 1234 以后不能声称该请求实际上是由其他人伪造的。让我们花点时间更详细地探讨每个方面,从请求的来源开始。
Ultimately the decision to honor a given request comes down to a yes or no answer, but there are several different requirements we need to consider to make this binary decision. First, we need to know whether a request originated from the same user as it claims. In other words, if the request states it was sent by user 1234, we need to be able to tell for sure that it did indeed come from user 1234 (for our definition of what it means to be user 1234). Next, we need to be sure that the request content hasn’t been corrupted or tampered with. Finally, it may become important down the line that a disinterested third party is able to verify, with no doubt, that a request undeniably came from a specific user. In other words, if we find a request claiming to have been sent from user 1234 (and this is verifiable), user 1234 must not be able to later claim that the request was actually forged by someone else. Let’s take a moment to explore each of these aspects in more detail, starting with the origin of a request.
这我们需要的第一个也是最重要的能力是确定请求的来源。在这种情况下,我们不关心地理来源,而是关心发送请求的用户,这使我们想到了身份的概念。
The first and most important ability we need is establishing where a request originated. In this case, we’re not concerned with a geographical origin but with the user who sent the request, leading us to the concept of identity.
与现实世界中的身份概念不同,我们对驾照或出生证明不感兴趣。相反,我们真正关心的是,如果一个请求声称来自用户 1234,那么该请求也有某种证明只能由以用户 1234 身份注册我们服务的同一方提供。有很多方法来完成这个,我们将在 30.2 节中讨论,但现在让我们继续讨论我们的意思正直。
Unlike the concept of identity in the real world, we’re not interested in drivers’ licenses or birth certificates. Instead, all we actually care about is that if a request claims to come from user 1234, then that request also has some sort of proof that can only be supplied by the same party that registered with our service as user 1234. There are many ways to accomplish this, which we’ll discuss in section 30.2, but for now let’s move on to discussing what we mean by integrity.
正直指的是确定收到的请求内容与发送时的内容完全相同,这是 API 安全难题的关键部分。没有它,即使我们可以确定请求的来源(例如,声称来自用户 1234 的请求具有用户 1234 的凭据),我们也无法确定请求的内容从未被篡改发送后。如果我们不能确定请求与发送时的请求是否相同,那么知道它来自哪里就没有那么有用了。
Integrity refers to the certainty that the content of a request as received is exactly as it was when sent, and it’s a critical piece of the API security puzzle. Without it, even though we can be sure of the origin of a request (e.g., that a request claiming to be from user 1234 has the credentials for user 1234), we can’t be certain that the content of the request was never tampered with after it was sent. And if we can’t be sure that the request is the same as it was when sent, it’s not really all that useful to know where it came from.
虽然不常见,但请求在传输过程中肯定会被更改。这可能包括无意的错误(例如,某些网络基础设施出现故障并弄乱了数据)一直到恶意的错误(例如,有人拦截了请求并故意篡改了它),但重点仍然是:我们必须我们能够确定请求的内容在发送后是否以任何方式被修改,并拒绝任何在传输过程中被修改的请求。
While uncommon, requests can certainly be altered in transit. This might range from an unintentional mistake (e.g., some networking infrastructure is faulty and messed with the data) all the way to the malicious (e.g., someone intercepted the request and deliberately tampered with it), but the point remains: it’s critical that we’re able to ascertain whether a request’s content has been modified in any way after it was sent and reject any requests that have been modified in transit.
此外,尽管我们通常可以依赖网络层安全性(例如,TLS),但这样做意味着以表面价值接收传入请求,而不是验证请求本身,并假设请求以正确的方式传输。如果我们可以假设每个人总是在网络安全方面做正确的事情,那会很可爱,但事实证明,我们的其他需求导致了这一方面的出现自由。
Additionally, even though we can often rely on network-layer security (e.g., TLS), doing so means taking incoming requests at face value rather than verifying the request itself, and assuming that the request was transported in the correct way. And while it would be lovely if we could assume that everyone always does the right thing with network security, it turns out that our other requirements lead to this aspect coming along for free.
不可否认性指的是一旦我们验证了请求的来源,该来源就不能否认它们是来源。在这一点上,你可能会想,“但为什么这甚至是一个问题?如果我们能够查明来源,那么这个要求就已经隐含了!” 事实证明,这几乎是正确的,但还有更多。
Nonrepudiation refers to the idea that once we’ve verified the origin of a request, that origin can’t then deny that they were the origin. At this point, you might be thinking, “But why is this even an issue? If we can verify the origin, then this requirement is implied already!” It turns out that this is almost right, but there’s a bit more to it.
真正令人担忧的是,设计一个对称的凭证系统非常容易,其中证明您是用户 1234 所需的凭证与验证请求来自用户 1234 所需的凭证相同,有点像两者之间的共享秘密两方。直到现在,这种对称凭证的想法才成为问题,因为关键假设是 API 请求永远不应该来自 API 服务器。但是当其他人(不是用户 1234 也不是 API 服务器)需要验证用户 1234 是请求的真正来源时会发生什么?这个第三方如何区分真正来自用户 1234 的请求与由 API 服务器滥用其共享凭证伪造的请求之间的区别,该凭证仅用于验证用户 1234?
The real concern is that it’s very easy to design a credential system that is symmetrical, where the credentials needed to prove that you are user 1234 are the same ones needed to verify that a request came from user 1234, sort of like a shared secret between the two parties. This idea of a symmetric credential would not have been an issue until now because of the key assumption that API requests should never originate from an API server. But what happens when someone else (not user 1234 and not the API server) needs to verify that user 1234 was the true origin of the request? How can this third party tell the difference between a request with a true origin of user 1234 and one forged by an API server misusing its shared credential that was only intended to be used for verifying user 1234?
这一要求非常明确地规定,用户证明他们是请求的真正来源的机制也不得与 API 服务器共享。换句话说,用于证明和验证 API 请求来源的凭据必须是非对称的,每一方都有不同的凭据。通过这样做,我们确保用户是唯一有权访问凭证的一方,并且查看用户请求的第三方可以确定它来自该用户并且不是由伪造的API 服务器。
This requirement pretty plainly dictates that the mechanism by which a user proves that they are the true origin of a request must not also be shared with an API server. In other words, the credentials used to prove and verify the origin of an API request must be asymmetrical, with different credentials for each party. By doing this, we ensure the user is the only party with access to the credential, and a third party looking at a request from the user can be sure it originated with that user and was not forged by the API server.
尽管有许多设计竞争者,一个选择脱颖而出:数字签名。我们将在下一节中详细介绍数字签名的工作原理,但重要的是要介绍数字签名的基本知识以及它们为何适合这些要求。
While there are many design contenders, one option stands out: digital signatures. We’ll dive into the details about how digital signatures work in the next section, but it’s important to cover the basics of what they are and why they’re a good fit given these requirements.
从根本上说,数字签名只不过是一大块字节。也就是说,这些字节代表一个值,排除合理怀疑,只能使用一对特殊的凭据生成和验证。这意味着如果不拥有该凭证,则实际上不可能重新创建使用一个凭证生成的签名。
Fundamentally, a digital signature is nothing more than a chunk of bytes. That said, these bytes represent a value that, beyond a reasonable doubt, can only be generated and verified using a special pair of credentials. This means that a signature generated with one credential is effectively impossible to recreate without possessing that credential.
然而,这些加密数字签名最重要的区别属性与凭证的不对称性有关。这种不对称意味着用于生成签名的凭据与用于验证签名的凭据不同。换句话说,这对凭证中的每个凭证都有一个单一的作用:一个用于生成签名,另一个用于验证签名。
However, the most important distinguishing property of these cryptographic digital signatures has to do with the asymmetry of the credentials. This asymmetry means that the credential used for generating the signature is not the same as the one used for verifying it. In other words, each credential in the pair has a single role: one is used to generate a signature and the other is used to verify the signature.
在验证 API 请求时,数字签名正好符合要求。首先,它们的加密性质意味着证明签名是由拥有正确凭据的人生成的本身很简单。其次,签名取决于被签名的消息这一事实意味着如果消息以任何方式被更改,则签名无效。最后,因为只有一方能够生成数字签名,所以该方以后无法声称签名是伪造的。换句话说,该机制满足第 30.1 节中列出的所有三个关键标准。
When it comes to authenticating API requests, digital signatures happen to fit the requirements quite nicely. First, their cryptographic nature means that it’s inherently simple to prove that the signature was generated by someone possessing the correct credentials. Next, the fact that the signature is dependent on the message being signed means that a signature is invalid if the message was changed in any way whatsoever. Finally, because there is one and only one party capable of generating digital signatures, there is no way that party can later claim that a signature has been forged. In other words, this mechanism meets all three of the key criteria listed in section 30.1.
但是这些签名究竟是如何工作的呢?我们如何将它们集成到 Web API 中?使用数字签名进行请求认证的过程涉及三个不同的阶段。首先,用户创建一个公私密钥对。接下来,我们允许用户注册 API 服务,基于对的公共部分与系统建立身份。从那时起,用户可以简单地使用他们的私钥对所有请求进行数字签名,API 服务器可以使用之前关联的公钥验证这些签名。
But how exactly do these signatures work? And how can we integrate them into a web API? The process of using digital signatures for request authentication involves three distinct stages. First, the user creates a public-private keypair. Next, we allow users to register with the API service, establishing an identity with the system based on the public half of the pair. From that point on, the user can simply digitally sign all requests with their private key and the API server can verify those signatures using the previously associated public key.
如果这听起来有点简单,请不要担心;在下一节中,我们将更多地介绍这些概念细节。
If this sounds a bit simplistic, don’t worry; in the next section, we’ll cover these concepts in much more detail.
作为我们在 30.2 节中了解到,使用数字签名进行请求身份验证是用户和 API 服务器双方的多阶段过程。我们将从整个系统的基础开始我们的旅程:生成适当的非对称凭证。
As we learned in section 30.2, using digital signatures for request authentication is a multistage process on the part of both the user and the API server. We’ll begin our journey at the foundation of this entire system: generating the proper asymmetric credentials.
前用户可以做任何事情,他们首先需要生成一个密钥对。此密钥对是一种特殊类型的凭证,由两部分组成:私钥和公钥。将私钥绝对保密是至关重要的,因为正如我们之前了解到的,拥有正确的凭证就是身份的定义。换句话说,拥有正确的凭证就足够了,并且凭证上没有照片可以与出示它的人进行比较作为二次检查。
Before a user can do anything, they first need to generate a keypair. This keypair is a special type of credential that consists of two pieces: a private key and a public key. It’s critical that the private key be kept absolutely secret because, as we learned earlier, possession of the correct credential is the definition of identity. In other words, possessing the right credential is proof enough and there’s no photo on the credential to compare with the person presenting it as a secondary check.
但是为什么用户必须负责生成这对凭据呢?为什么服务器不能生成它们然后简单地给用户私钥供以后使用?正如我们从不可否认性要求中了解到的那样,拥有一对非对称凭证(而不是共享秘密)的全部意义在于防止用户否认他们签署了请求。这是可行的,因为用于签署请求的凭证只在用户手中,不会与 API 服务器共享。在这种情况下,如果我们允许 API 服务器生成凭证,我们只能保证他们没有为自己保留该密钥的副本。此外,我们不得不担心私钥通过互联网传输,这会带来(很小但真实的)被拦截的风险。因此,
But why does the user have to be responsible for generating this pair of credentials? Why can’t the server generate them and then simply give the user the private key for use later on? As we learned with the requirement of nonrepudiation, the entire point of having an asymmetric pair of credentials (rather than a shared secret) is to prevent the user from denying that they signed a request. This works because the credential used to sign requests is only ever in the hands of the user and isn’t shared with the API server. In this case, if we allow the API server to generate the credential, we have nothing but their guarantee that they haven’t kept a copy of that key for themselves. Further, we have to worry about the private key being transmitted over the internet, which runs the (small, but real) risk of being intercepted. As a result, if we truly want to meet the requirement for nonrepudiation, the private key can never leave the hands of the user.
我们如何生成这个密钥?那里有很多工具,例如ssh-keygen在大多数 Linux 系统上,但以下 TypeScript 代码显示了如何以编程方式执行此操作。
How can we generate this key? There are many tools out there, such as ssh-keygen on most Linux systems, but the following TypeScript code shows how you can do so programmatically.
Listing 30.1 Generating a set of credentials
const crypto = require('crypto'); interface KeyPair { publicKey: string; privateKey: string; } function generateCredentials(size = 2048): Promise<KeyPair> { return new Promise<KeyPair>( (resolve, reject) => { crypto.generateKeyPair('rsa', { modulusLength: size }, (err, pub, priv) => { if (err) { return reject(err); } ❶ return resolve({ publicKey: pub.export({ type: 'pkcs1', format: 'pem' }), ❷ privateKey: priv.export({ type: 'pkcs1', format: 'pem' }) }); }); }); }
❶ If any errors arise, simply reject the promise.
❷ Use the string serialized data for the public and private key values.
在客户执行这样的代码后,他们将得到一个公钥,该公钥序列化为一个我们可以与他人共享的字符串。现在我们需要以某种方式将这个公钥传输到 API 服务器,以便我们可以建立我们的身份。
After clients execute code like this, they’ll be left with a public key that serializes to a string we can share with others. Now we need to somehow transfer this public key over to the API server in a way that we can establish our identity.
作为我们在 30.1.1 节中了解到,身份通常是一个重要的概念,但是在请求身份验证时,这个概念的具体解释与我们在日常生活中所期望的有点不同. 特别是,关于识别用户根本没有什么绝对的。换句话说,API 服务器永远不会验证用户固有的某些东西(例如,他们的指纹),而是将身份定义为持有建立该身份的凭证的任何人。简而言之,证明您拥有分配给某个身份的秘密就足以证明您就是那个身份。
As we learned in section 30.1.1, identity is an important concept in general, but when it comes to request authentication, the specific interpretation of this concept is a bit different from what we’ve come to expect in our day-to-day lives. In particular, there is simply nothing absolute about identifying a user. In other words, the API server is never going to verify something intrinsic about a user (e.g., their fingerprints), but instead defines an identity as anyone holding the credential that established that identity. Put simply, proving you possess a secret assigned to an identity is enough to prove that you are that identity.
这使得注册的想法更简单。在这种情况下,我们可以允许新用户通过提供公钥凭证来注册 API,我们将为该用户生成一个唯一标识符。这一系列事件如图 30.1 所示。
This makes the idea of registration simpler. In this case, we can allow a new user to register with the API by providing the public key credential, and we will generate a unique identifier for this user. This sequence of events is shown in figure 30.1.
图 30.1 使用公钥注册账号的顺序
Figure 30.1 Sequence of registering an account with a public key
使用这个简单的原子标准创建方法,我们进行了一次 API 调用,现在有了一个唯一的标识符可用于未来的请求(例如,用户 1234),并且公钥凭证可用于在接受请求之前验证签名。请注意,这也避免了使用更传统的方法(例如用户名和密码)对服务进行身份验证的要求。相反,该方案从一开始就完全依赖于数字签名。
Using this simple, atomic standard create method, we’ve made a single API call and now have a unique identifier to use in future requests (e.g., user 1234), and the public key credential can be used to verify signatures before accepting requests. Note that this also avoids the requirement to authenticate with the service using a more traditional method such as a username and password. Instead, this scheme relies entirely on digital signatures from the very beginning.
既然我们已经介绍了如何生成凭据并安全地与 API 服务器共享它们以建立对身份的共同理解,那么我们究竟如何使用这些凭据钥匙?
Now that we’ve covered how to generate credentials and safely share them with the API server to establish a shared understanding of identity, how exactly do we use these keys?
没有关于密码学或数论的细节太多了,关于数字签名要记住的重要一点是,它是一个特殊的数字,可以使用私钥轻松计算,但没有私钥几乎不可能计算。同样,可以使用公钥快速轻松地验证这个特殊数字,但不能使用同一个公钥生成相同的签名值。在某种程度上,它有点像一个安全的玻璃盒子:只有拥有钥匙的人才能在里面放东西,但一旦拥有,任何人都可以看到该物品,并知道它只是被拥有钥匙的人放在那里的。
Without going into too much detail on cryptography or number theory, the important thing to remember about a digital signature is that it’s a special number that can be easily computed using a private key but is almost impossible to compute without that key. Similarly, this special number can be quickly and easily verified using a public key, but that same public key can’t be used to generate the same signature value. In a way it’s a bit like a secure glass box: only the person with the key can put something inside, but once they have anyone can see the item and know that it was placed there only by someone possessing the key.
为了计算签名,我们将请求主体与私钥结合起来,我们可以依赖标准库,例如 Node.js 的crypto包处理生成签名的实际艰巨的数学工作。
To calculate a signature, we combine the request body along with the private key, and we can rely on standard libraries such as Node.js’s crypto package to handle the actual hard mathematical work of generating the signature.
Listing 30.2 Signing an arbitrary string
const crypto = require('crypto'); function generateSignature(payload: string, privateKey: string): string { const signer = crypto.createSign('rsa-sha256'); signer.update(payload); signer.end(); return signer.sign(privateKey); }
验证签名同样简单。我们获取签名、我们期望已签名的有效负载和相应的公钥,并使用标准库函数将这三者结合起来。该函数的结果将是一个简单的布尔值,说明签名是否已签出。
Verifying a signature is equally straightforward. We take the signature, the payload that we expect to have been signed, and the corresponding public key and combine these three using standard library functions. The result of the function will be a simple Boolean value stating whether the signature checks out.
Listing 30.3 Verifying an arbitrary signature
const crypto = require('crypto'); function verifySignature( payload: string, signature: string, publicKey: string): Boolean { const verifier = crypto.createVerify('rsa-sha256'); verifier.update(payload); verifier.end(); return verifier.verify(publicKey, signature); }
关于这两个示例,可能看起来令人惊讶的一件事是payload参数是一个字符串,而不是一个对象或 API 请求的其他表示形式。但这实际上给我们带来了一个重要的问题:我们究竟应该签署什么?
One thing that might seem surprising about both of these examples is the fact that the payload parameter is a string rather than, say, an object or some other representation of an API request. But this actually brings us to an important question: what exactly should we be signing?
事实证明,这个问题比听起来要复杂得多。payload在下一节中,我们将讨论在生成或验证数字时如何确定将什么放入参数中签名。
It turns out that this question is a lot more complicated than it sounds. In the next section, we’ll talk about how to determine what to put into the payload parameter when generating or verifying a digital signature.
所以到目前为止,我们只是挥手说我们必须签署请求,但并没有真正具体说明这是如何运作的。让我们花点时间探讨在将请求发送到 API 服务器之前决定应该签署什么时我们需要考虑的一些方面。
So far we’ve sort of waved our hands around and said we must sign the request but haven’t really been all that specific about how this works. Let’s take a moment to explore some of the aspects we need to consider when deciding what should be signed before sending the request to the API server.
尽管只对请求主体签名可能很诱人,但事实证明,即使这样也比看起来有点棘手,因为请求主体要求我们序列化数据(例如,使用JSON.stringify()在 TypeScript 中),实际上有很多不同的序列化方法,甚至依赖于相同的序列化格式。例如,有多种字符编码和规范化形式,导致许多字符串在语义上可能是等价的,但仍然具有不同的字节表示形式。此外,对于包括 JSON 在内的大多数格式,通常没有关于属性在各种类似地图的结构中出现的顺序的规则。例如,虽然{"a": 1, "b": 2}在语义上与 相同{"b": 2, "a": 1},但这些值的字节表示完全不同。
As tempting as it might be to just sign the request body, it turns out that even this is a bit trickier than it seems because the request body requires us to serialize data (e.g., using JSON.stringify() in TypeScript), and there are actually lots of different ways to serialize things, even relying on the same serialization format. For example, there are a variety of character encodings and normalization forms, leading to many strings that might be semantically equivalent but still have different byte representations. Further, with most formats, including JSON, there are typically no rules about the order in which properties appear in various map-like structures. For example, while {"a": 1, "b": 2} is semantically the same as {"b": 2, "a": 1}, the byte representations of these values are completely different.
由于计算数字签名是在原始的、不透明的字节上进行的,这意味着内容的低级字节表示实际上非常重要。例如,如果验证签名的一方使用同一语义消息的不同字节表示,这将导致签名失败,而事实上,内容实际上并没有改变。相反,字节表示只是以不同的方式计算。
Since calculating a digital signature operates on the raw, opaque bytes, this means that the low-level byte representation of the content is actually incredibly important. If, for instance, the party verifying a signature uses a different byte representation of the same semantic message, this would result in a failed signature when, in truth, the content hasn’t actually changed. Instead, the byte representation is simply being computed differently.
让事情变得更复杂的是,我们必须记住,API 请求可能涉及的不仅仅是请求主体。例如,当删除使用 HTTP 作为传输机制的资源时,HTTP 请求主体为空(而不是像{"id": "chatRooms/1"})。相反,预期的操作在 HTTP 动词 ( ) 中编码,DELETE要删除的目标在 URL (/chatRooms/1). 此外,HTTP 标头中可能还有其他信息,这些信息对请求的含义和完整性很重要(例如,发送请求的时间),并且确实应该包含在计算签名中。这些因素中的每一个都使我们拥有某种计算请求指纹的方法变得更加重要,该请求指纹可以唯一地表示给定的请求,并最终在使用私钥计算数字签名时充当有效负载。
To make things even more complicated, we have to remember that the API request may involve far more than just the request body. For example, when deleting a resource using HTTP as the transport mechanism, the HTTP request body is empty (and not something like {"id": "chatRooms/1"}). Instead, the intended action is encoded in the HTTP verb (DELETE) and the target to delete is represented in the URL (/chatRooms/1). Additionally, there may be other pieces of information in the HTTP headers that are important to the meaning and integrity of the request (e.g., the time at which the request was sent) and really should be included in calculating the signature. Each of these factors makes it all the more important that we have some way of computing a request fingerprint that can uniquely represent a given request and ultimately act as the payload when computing a digital signature with a private key.
但是我们如何定义这个指纹呢?为了弄清楚这一点,我们需要考虑签名中需要包含 HTTP 请求的哪些部分。幸运的是,这个问题的答案非常简单。首先,我们需要合并 HTTP 方法、URL(主机和路径)和请求正文。此外,如果存在,我们应该包括请求发送时间的指示;这通常出现在名为“日期”的 HTTP 标头中,以支持拒绝可能太旧的请求(这些字段的摘要显示在表 30.1 中)).
But how do we define this fingerprint? To figure that out, we need to consider which pieces of the HTTP request need to be included in the signature. Luckily, the answer to this is pretty simple. First, we need to incorporate the HTTP method, URL (host and path), and request body. Additionally, if present, we should include an indication of the time at which the request was sent; most often this is present in a HTTP header called “Date,” in order to support rejecting requests that might be too old (a summary of these fields is shown in table 30.1).
Table 30.1 Components necessary for fingerprinting a HTTP request
如我们所见,数据来自不同的位置,这使我们的工作有点复杂。为了简化这个,我们可以做两件事。首先,我们可以将 HTTP 请求的第一行作为一些特殊的东西,通常称为请求目标,包括 HTTP 方法(例如,POST) 和路径(例如,/chatRooms/1)。其次,我们可以计算请求体的哈希值并将其存储在一个名为“Digest”的单独 HTTP 标头(Base64 格式)中,而不是依赖于对可能变得过大的请求体进行签名。依靠这个派生字段,我们可以在限制指纹大小的同时保持指纹的完整性和安全性。
As we can see, the data comes from a variety of locations, which makes our job a bit more complicated. To simplify this, we can do two things. First, we can treat the first line of the HTTP request as something special, often called the request target, including both the HTTP method (e.g., POST) and the path (e.g., /chatRooms/1). Second, rather than relying on signing the request body, which could get excessively large, we can calculate a hash of the body and store that in a separate HTTP header (in Base64 format) called “Digest.” By relying on this derived field, we can maintain the integrity and security of the fingerprint while limiting its size.
Listing 30.4 Generating the hash value for a digest header
const crypto = require('crypto'); function generateDigestHeader(body: string): string { return 'SHA-256=' + crypto ❶ .createHash('sha256') .update(body) .digest() .toString('base64'); ❷ }
❶我们在哈希前面加上所用哈希算法的规范(在本例中为 SHA-256)。
❶ We prefix the hash with a specification of the hash algorithm used (in this case, SHA-256).
❷ The hash should be Base64-encoded.
完成这些事情后,我们需要组装各个部分。为此,我们可以使用组件的有序列表(例如,请求目标、主机、日期,然后是摘要),适当地格式化它们(例如,将标头转换为小写字符),并将每个组件放在一个带有换行符的字符串中每个之间的字符。
Once we’ve done these things, we need to assemble the pieces. To do this, we can use an ordered list of the components (e.g., request target, host, date, and then digest), format them appropriately (e.g., convert headers to lowercase characters), and place each one in a string with newline characters between each one.
Listing 30.5 Generating a fingerprint given a HTTP request
const HEADERS = ['(request-target)', 'host', 'date', 'digest']; function generateRequestFingerprint( request: HttpRequest, headers = HEADERS): string { return headers.map((header) => { let value; if (header === '(request-target)') { ❶ value = `${request.method.toLowerCase()} ${request.path}`; } else if (header === 'digest') { ❷ value = generateDigestHeader(request.body); } else { ❸ value = request.headers[header]; } return `${header.toLowerCase()}: ${value}`; ❹ }).join('\n'); }
❶ “(request-target)”字段应包括小写的 HTTP 方法和路径。
❶ The “(request-target)” field should include the lowercase HTTP method and the path.
❷ The “digest” field should generate a hash of the request body.
❸ All other values should be just as specified in the header of the request.
❹ Finally, return a set of key-value pairs, separated by a colon (just like headers are).
Listing 30.6 Example fingerprint used for signing HTTP requests
(request-target): patch /chatRooms/1 ❶ host: example.org date: Tue, 27 Oct 2020 20:51:35 GMT digest: SHA-256=HV9PltG0QPRNsl1FB7ebQA8XPasvPyRg6hhU0QF2l4M= ❷
❶特殊的“(request-target)”字段被视为与任何其他标头一样。
❶ The special “(request-target)” field is treated like any other header.
❷ We also include the additional digest header.
这种键值对的规范格式足以作为请求的指纹,更重要的是,我们最终可以将其用作计算数字签名的有效负载。它具有有关请求的目标资源 ( /chatRooms/1)、操作 ( PATCH)、目的地 ( example.org)、时间 (10 月 27 日) 和正文(以 SHA-256 哈希摘要的形式)的所有信息,以便唯一标识请求之后。
This canonical format of key-value pairs is sufficient as a fingerprint of the request and, more importantly, is something we could eventually use as the payload for computing a digital signature. It has all the information about the request’s target resource (/chatRooms/1), action (PATCH), destination (example.org), time (Oct. 27), and the body (in the form of a SHA-256 hash digest) in order to uniquely identify the request later.
下一个问题是我们需要以某种方式通知 API 服务器我们是如何计算这个指纹的。在下一节中,我们将探讨如何将这些参数与服务器进行通信,以确保它能够正确地为要求。
The next problem is that we need to somehow inform the API server of exactly how we came to compute this fingerprint. In the next section, we’ll explore how we can communicate these parameters with the server to ensure it’s able to properly come up with the same fingerprint for the request.
后我们已经计算了请求指纹,我们不能只是签名然后收工。为了确保 API 服务器能够正确验证签名,它必须能够根据入站请求计算出相同的指纹值。为此,我们需要包含一些关于指纹和签名的元数据。
After we’ve computed the request fingerprint, we can’t just sign it and call it a day. In order to ensure that the API server is capable of verifying the signature correctly, it must be able to compute the same fingerprint value given the inbound request. And to do that, we need to include a few bits of metadata about both the fingerprint and the signature.
首先,我们需要弄清楚指纹中包含哪些标头,而且至关重要的是,它们的顺序是什么。只有标头列表是不够的,因为以不同的顺序呈现它们会导致不同的指纹。接下来,我们需要以某种方式告诉 API 服务器应该使用哪个公钥来验证签名。这个 ID 可以等同于注册时创建的用户 ID,因为每个用户只有一个公钥。最后,我们还应该包括用于生成签名的算法,以便 API 服务器可以使用相同的算法对其进行验证。这些字段的摘要显示在表 30 中。2.
First, we need to be clear about which headers were included in the fingerprint and, critically, in what order. Only having the list of headers is insufficient because rendering them in a different order will result in a different fingerprint. Next, we need to somehow tell the API server which public key should be used to verify the signature. This ID can be equivalent to the user ID created during registration since each user has exactly one public key. Finally, we should also include the algorithm used to generate the signature so that the API server can verify it using the same algorithm. A summary of these fields is shown in table 30.2.
Table 30.2 Components necessary to define a signature
|
Ordered list of headers used to generate the request fingerprint |
|
一旦我们获得了所有这些信息,我们就可以将其组合起来,并将生成的值与请求一起传递到一个特殊的签名标头中。
Once we have all this information, we can assemble it and pass the generated value along with the request in a special signature header.
Listing 30.7 Generating a signature header given a fingerprint and other components
const HEADERS = ['(request-target)', 'host', 'date', 'digest']; interface SignatureMetadata { keyId: string; algorithm: string; headers: string; signature: string; } function generateSignatureHeader( fingerprint: string, userId: string, privateKey: string, headers = HEADERS): string { const signatureParts: SignatureMetadata = { ❶ keyId: userId, algorithm: 'rsa-sha256', headers: headers.map((h) => h.toLowerCase()).join(' '), signature: generateSignature(fingerprint, privateKey) }; return Object.entries(signatureParts).map(([k, v]) => { ❷ return `${k}="${v}"`; }).join(','); }
❶ Specify a list of the different signature components.
❷ Combine parts into comma-separated quoted key-value pairs.
这个函数的输出最终应该是我们需要充分定义签名的每个不同字段的逗号分隔字符串。
The output of this function should end up being a comma-separated string of each of the different fields we need to adequately define a signature.
Listing 30.8 Example of the value for a signature header
keyId="1234",algorithm="rsa-sha256",headers="(request-target) ➥ host date digest",signature="mgBAQEsEsoBCgIOBiNfum37y..."
现在我们有了所有的零件,剩下的就是组装所有东西了。这意味着我们需要接受一个 HTTP 请求并附加两个新的标头:一个摘要标头和一个签名标头。我们可以使用之前定义的辅助方法来完成繁重的工作。
Now that we have all the pieces, all that’s left is to assemble everything. This means that we need to take an HTTP request and attach two new headers: a digest header and a signature header. We can use our helper methods defined earlier to do the heavy lifting.
Listing 30.9 Function to sign a request (assigning relevant headers)
const HEADERS = ['(request-target)', 'host', 'date', 'digest']; function signRequest( request: HttpRequest, userId: string, privateKey: string, headers = HEADERS): HttpRequest { request.headers['digest'] = generateDigestHeader(request.body); ❶ const fingerprint = generateRequestFingerprint(request, headers); ❷ request.headers['signature'] = generateSignatureHeader( fingerprint, userId, privateKey, headers); ❸ return request; ❹ }
❷ Generate the request fingerprint needed for the signature.
❸ Update the signature header.
❹ Return the augmented request.
此函数的输出是一个请求,该请求应包括设置为请求主体哈希的摘要标头以及签名标头,签名标头不仅包括数字签名,还包括 API 服务器验证请求所需的所有信息签名值本身。
The output of this function is a request that should include both a digest header set to a hash of the request body as well as a signature header that not only includes the digital signature, but also all the information necessary for the API server to verify the signature value itself.
Listing 30.10 Example of a complete signed HTTP request
PATCH /chatRooms/1 Host: example.org Digest: SHA-256=HV9PltG0QPRNsl1FB7ebQA8XPasvPyRg6hhU0QF2l4M= Signature: keyId="1234",algorithm="rsa-sha256",headers=" (request-target) host date digest", signature="mgBAQEsEsoBCgIOBiNfum37y..." Date: Tue, 27 Oct 2020 20:51:35 GMT {"title":"New title"}
请注意,实际请求中标头的顺序并不重要。这是因为我们已经指定了在验证签名时应该使用标头生成指纹的顺序。接下来我们需要考虑的是 API 服务器究竟如何验证签名并最终验证请求。我们接下来看这个部分。
Note that the order of the headers in the actual request is not important. This is because we’ve specified the order in which headers should be used to generate the fingerprint when verifying the signature. The next thing we need to consider is how exactly an API server can go about verifying a signature and ultimately authenticating a request. Let’s look at this in the next section.
作为正如您所料,我们可以依靠相同的算法来验证签名,从而验证请求。但是我们到底需要验证什么?
As you’d expect, we can rely on the same algorithm to verify a signature and, therefore, authenticate a request. But what exactly do we need to validate?
首先,我们需要确保摘要标头实际上是请求正文中内容的真实表示。如果不是,这意味着请求正文在计算哈希后发生了变化,请求应该被拒绝。接下来,我们需要使用签名标头中提供的模式重新计算请求的指纹。之后,我们需要使用该标头中提供的密钥标识符来查找应该用于验证签名的公钥。最后,我们应该使用公钥和指纹来验证签名是否正确。
First, we need to ensure that the digest header is actually a true representation of the content in the request body. If not, this means that the request body has changed since the hash was calculated and the request should be rejected. Next, we need to recompute the fingerprint of the request using the schema provided in the signature header. After that, we need to use the key identifier provided in that header to look up the public key that should be used to verify the signature. Finally, we should use the public key and fingerprint to verify that the signature is correct.
虽然这听起来可能很多,但我们已经编写了大部分将完成繁重工作的代码。
While this might sound like a lot, we’ve already written the majority of the code that will do the heavy lifting..
Listing 30.11 Verifying a signature given a request object
function parseSignatureHeader(signatureHeader: string): ❶ SignatureMetadata { const data = Object.fromEntries( signatureHeader .split(',') ❷ .map((pair) => pair.split('=')) ❸ .map(([k, v]) => [k, v.substring(1, v.length-1)])); ❹ data.headers = data.headers.split(' '); ❺ return metadata; } async function verifyRequestSignature(request: HttpRequest): Promise<Boolean> { if (generateDigestHeader(request.body) !== request.headers['digest']) { ❻ return false; } const metadata = parseSignatureHeader( request.headers['signature']); ❼ const fingerprint = generateRequestFingerprint( request, metadata.headers); ❽ const publicKey = (await database.getUser( metadata.keyId)).publicKey; ❾ return verifySignature( fingerprint, metadata.signature, publicKey); ❿ }
❶ First we need a function to parse a signature header into separate components.
❷ Split the k="v",k="v" pairs.
❻ Verify that the request body matches the digest header.
❼ Parse the signature header into a bunch of pieces.
❽ Determine the payload that was signed based on the headers.
❾ Figure out the public key for the provided ID (stored in the database somewhere).
❿ Verify the signature against the fingerprint with the public key.
有了它,我们现在可以生成一些凭据并向 API 服务器注册,对出站请求进行数字签名,并对来自用户。
And with that, we now have the ability to generate and register some credentials with the API server, digitally sign an outbound request, and authenticate an inbound request from a user.
这此模式的最终 API 定义非常简单,仅涉及使用关联的公钥创建新用户并稍后通过其 ID 检索这些用户的方法。前面探讨了如何签署请求和验证签名的实现,因此本节不会重现相同的代码。
The final API definition for this pattern is pretty straight forward, involving just the method for creating new users with the associated public keys and retrieving those users by their ID later. The implementations for how to sign requests and verify signatures were explored earlier, so this section won’t reproduce that same code.
Listing 30.12 Final API definition
abstract class ChatRoomApi { @post("/users") CreateUser(req: CreateUserRequest): User; @get("{id=users/*}") GetUser(req: GetUserRequest): User; } interface CreateUserRequest { resource: User; } interface GetUserRequest { id: string; } interface User { id: string; publicKey: string; // ... }
自从有这么多不同的身份验证方法可用,在选择一种而不是其他方法时显然需要权衡取舍。即使对请求进行数字签名几乎可以肯定是最安全的选择,但这并不是在所有情况下都是正确的选择。事实上,对这种设计最常见的反对意见是它对场景来说太过分了。
Since there are so many different authentication methods available, there are obviously trade-offs when choosing one over the others. And even though digitally signing requests is almost certainly the most secure option available, this doesn’t make it the right choice in all scenarios. In fact, the most common objection to this type of design is that it’s overkill for the scenario.
例如,在许多情况下,不可否认性更像是一种“可有可无”而不是真正的要求。坚持不可否认性的理由是,当您可能会提供签名请求供其他人稍后执行(涉及可能完全信任或可能不完全信任的第三方),或者当 API 请求本身特别敏感时(例如,更新健康或财务记录) . 在这些情况下,不可否认性对于 API 服务器保护自己免受未来审计历史日志期间可能出现的不当指控(例如,用户声称他们从未提出过这些请求)很重要。
For instance, there are many cases where nonrepudiation is more of a “nice to have” than a true requirement. The argument for insisting on nonrepudiation is when you might provide signed requests for others to execute later (involving third parties that may or may not be fully trusted), or when the API requests themselves are particularly sensitive (e.g., updating health or financial records). In these cases, nonrepudiation is important for the API server to protect itself from accusations of impropriety (e.g., a user claiming they never made those requests) that might arise during a future audit of historical logs.
虽然这些事情并非不可能,但它们肯定不是非常普遍。毕竟,当我们在 Facebook 或 Twitter 上更新我们的数据时,我们并不是真的打算事后拒绝 API 请求。因此,其他系统(例如使用具有共享密钥的 HMAC)当然值得探索。虽然我们不会详细介绍使用 HMAC 或其他使用短期访问令牌的标准身份验证机制(例如,OAuth 2.0;https://oauth.net/2/),但这些几乎肯定是任何 API 的有效选项要求稍微宽松一些,可以期望依赖在传输层实施的适当安全协议。
While these things are not impossible, they are certainly not incredibly common. After all, when we update our data on Facebook or Twitter, we’re not really planning to repudiate API requests after the fact. As a result, other systems (such as using HMAC with a shared secret) are certainly worth exploring. While we won’t go into detail of using HMAC or other standard authentication mechanisms using short-lived access tokens (e.g., OAuth 2.0; https://oauth.net/2/), these are almost certainly valid options for any API that has slightly less stringent requirements and can expect to rely on proper security protocols implemented at the transport layer.
但是,为什么不在可用时依赖最安全的选择呢?一个常见的答案是,这种类型的密码学计算量更大,最终由 API 服务器本身处理,而不是在堆栈的较低级别处理。事实上,即使在公钥密码学的普遍使用中,例如 SSL,系统也只是预先使用非对称系统来交换临时对称密钥,此后依赖于不太密集的对称算法。因此,关注可用计算资源的系统可能会选择更高效的替代方案高效的。
But why not just rely on the safest option when it’s available to you? One common answer is that this type of cryptography is more computationally intensive and ultimately is handled by the API server itself rather than at a lower level in the stack. In fact, even in the common usage of public-key cryptography, such as SSL, the system only uses the asymmetric system up front to exchange a temporary symmetric key, relying on the less intensive symmetric algorithms from then on. As a result, systems that are concerned about available compute resources may opt for an alternative that is more efficient.
What is the difference between proving the origin of a request and preventing future repudiation? Why can’t the former dictate the latter?
Which requirement isn’t met by a shared secret between client and server?
Why is it important for the request fingerprint to include the HTTP method, host, and path attributes?
Are the digital signatures laid out in this pattern susceptible to replay attacks? If so, how can this be addressed?
There are three requirements for successful request authentication: proof of origin, proof of integrity, and prevention of repudiation.
Origin is the proof that a request came from a known source, without any uncertainty.
Integrity is focused on ensuring that the request itself has not been tampered with from the time it was sent from the known origin.
Nonrepudiation refers to the inability of the requester to later claim the request was actually sent from someone else.
Unlike real-world identity, authentication relies on identity being defined as possession of a secret key, sort of like a bearer bond.
When signing a request, the actual content being signed should be a fingerprint of the request, including the HTTP method, path, host, and a set of content in the HTTP body.
202 Accepted HTTP response code 283
403 禁止的 HTTP 错误 106–107、164、386、406
403 Forbidden HTTP error 106–107, 164, 386, 406
404 未找到 HTTP 错误 106–107、164、247、362、406
404 Not Found HTTP error 106–107, 164, 247, 362, 406
405 Method Not Allowed HTTP error 107
409 冲突 HTTP 错误 211、221、242、379
409 Conflict HTTP error 211, 221, 242, 379
412 前提条件失败 HTTP 错误 221、364、401
412 Precondition Failed HTTP error 221, 364, 401
500 Internal Server Error 164, 340, 406
access control, list method 110
listing associated resources 220–221
nonreciprocal relationship 223
addressing, array index 307–308
AnalyzeMessagesMetadata 接口 166
AnalyzeMessagesMetadata interface 166
resources for everything 59–61
array index addressing 307–308
naming association resource 210
standard method behavior 210–211
separation of associations 216
authentication request 416–430
authenticating requests 427–428
fingerprinting request 422–425
generating and verifying raw signatures 421–422
registration and credential exchange 420–421
BackupChatRoomJob resource 179
backward compatibility 338–344
handling mandatory changes 341–342
operating across parents 259–261
BatchCreateMessages method 259
BatchDeleteMessages method 256
BatchGetMessages method 256–257
array index addressing 307–308
bugs, backward compatibility 340–341
byteBuffer.readBigInt64BEInt(0) 101
byteBuffer.readBigInt64BEInt(0) 101
bytes, converting between resources and 319–321
聊天室资源 48, 109, 124, 178, 228, 240, 264, 271, 281, 301, 307, 314, 317, 323, 339, 343, 361, 378, 387, 402
ChatRoom resource 48, 109, 124, 178, 228, 240, 264, 271, 281, 301, 307, 314, 317, 323, 339, 343, 361, 378, 387, 402
ChatRoomStatEntry interface 283
ChatRoomStatEntry resource 283
collisions, request ID 378–380
handling mandatory changes 341–342
singleton sub-resources and 190
importing and exporting 321–322
copy_by_reference variable 248
criteria-based deletion result 275
singleton sub-resources 193–194
createChatRoomAndWait() 函数 163–164
createChatRoomAndWait() function 163–164
criteria-based deletion 270–277
validation only by default 274
cross-reference pattern 200–206
value versus reference 204–205
crypto.randomBytes() function 97
resources versus collections 147–149
stateless custom methods 149–151
exporting Twitter-like API 26–29
add and remove custom methods 221
cross-reference pattern 203–204
polymorphic resource validation 231–232
DELETE method 115, 146, 256, 365
singleton sub-resources 194–195
DeleteAllDataAndExplode() 方法 384
DeleteAllDataAndExplode() method 384
DeleteResourceRequest interface 233
criteria-based deletion 270–277
overloading standard delete method 401
diagrams, entity relationship 52–53
duplication, related resource 246
dynamic data structures 136–137
entity relationship diagrams 52–53
maximum delays and retries 410–411
converting between resources and bytes 319–321
filtering and field masks 327–328
handling related resources 323–324
identifiers and collisions 322–323
interacting with storage systems 318–319
external data, copy and move methods 247–248
maps and nested interfaces 129–131
updating dynamic data structures 136–137
criteria-based deletion results 272–274
filter syntax and behavior 305–310
fingerprinting request 422–425
backward compatibility 338–339
GDPR (General Data Protection Regulation) 149, 341
singleton sub-resources 192–193
GetValidationErrors() function 38
granularity, simplicity vs. 351–352
GUIs (graphical user-interface) 17
happiness, ubiquity vs. 353–355
hermetic filter expressions 307
hierarchical relationships 51–52
singleton sub-resources 195–196
fast and easy to generate 89–90
readable, shareable, and verifiable 90
hierarchy and uniqueness scope 94–95
converting between resources and bytes 319–321
filtering and field masks 327–328
handling related resources 323–324
identifiers and collisions 322–323
interacting with storage systems 318–319
InputConfig interface 316, 320, 327
ListGroupUsers() method 216, 221
messages in Twitter-like API 23–25
LROs (long-running operations) 154–174
pausing and resuming operations 168–169
many-to-many relationships 50, 208
maximum delays and retries 410–411
Membership Association resource 213
消息资源 49、109、227、240、256、276、314、317、320、328、397、402
Message resource 49, 109, 227, 240, 256, 276, 314, 317, 320, 328, 397, 402
MessageInputConfig interface 320
MessageOutputConfig interface 326
MessagePolicy sub-resource 339
MessageReviewReport resource 245
messages, listing Twitter-like API 23–25
add and remove custom methods 223–224
importing and exporting 317–318
effects on other methods 367–368
nonreciprocal relationship 223
null values 67–68, 135, 160, 366
Operation resource 158, 160, 282
OutputConfig interface 316, 320, 327
overloading standard delete method 401
parents, operating across 259–261
partial updates and retrievals 122–140
maps and nested interfaces 129–131
updating dynamic data structures 136–137
alternative implementations 139
PATCH method 114, 135, 139, 146, 266
permanence of resource identifiers 89
perpetual stability, versioning 345–346
persistence criteria, LROs 170–171
POST 方法 112、146、256、263、266、424
POST method 112, 146, 256, 263, 266, 424
pseudo-random number generators 97–98
registration, credential exchange and 420–421
importing and exporting 323–324
related resource duplication 246
references or in-line data 55–57
entity relationship diagrams 52–53
hierarchical relationships 51–52
self-reference relationships 50–51
listing associated resources 220–221
nonreciprocal relationship 223
resetting singleton sub-resources 195
fast and easy to generate 89–90
readable, shareable, and verifiable 90
hierarchy and uniqueness scope 94–95
resources for everything 59–61
entity relationship diagrams 52–53
converting between bytes and 319–321
partial updates and retrievals 122–140
restoring previous revisions 399–400
result counts, list method 110–111
server-specified retry timing 407–408
retrieving specific revisions 397–398
RunBackupChatRoomJobRequest 181
RunBackupChatRoomJobRequest 181
RunBackupChatRoomJobResponse 181
RunBackupChatRoomJobResponse 181
sample set, criteria-based deletion 275–276
security, singleton sub-resources and 190–191
self-reference relationships 50–51
backward compatibility 343–344
separation of associations 216
server-specified retry timing 407–408
sharing resource identifiers 90
generating and verifying 421–422
singleton sub-resources 189–199
upper and lower bounds 289–290
singleton sub-resources and 190
adding soft delete across versions 368
effects on other methods 367–368
modifying standard methods 362–364
stability, new functionality vs. 352–353
idempotence and side effects 107–108
on association resources 210–211
singleton sub-resources 192–195
stateless custom methods 149–151
storage systems 100–101, 318–319
String.IsNullOrEmpty() function 38
array index addressing 307–308
total count, pagination 293–294
totalResults integer field 293
TransationResult interface 160
transport, field masks 128–129
ubiquity, happiness vs. 353–355
UML (Unified Modeling Language) 52
unpredictability, of resource identifiers 90
singleton sub-resources 192–193
User resource 48, 208, 246, 310, 343, 378
UUIDs (universally unique identifiers) 87–102
validation only by default 274
verifying resource identifiers 90
backward compatibility 338–344
handling mandatory changes 341–342
granularity vs. simplicity 351–352
happiness vs. ubiquity 353–355
stability vs. new functionality 352–353